Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
588e750ede | ||
|
|
31e303c187 | ||
|
|
796c6390ac | ||
|
|
ecbf2617e4 | ||
|
|
9ef7d005fb | ||
|
|
842610b1af | ||
|
|
fca96396b9 | ||
|
|
ccf3fa260e | ||
|
|
1accfd9390 | ||
|
|
440ec41da8 | ||
|
|
1c8c0ca081 | ||
|
|
5f42c17caa | ||
|
|
eda5c752b7 | ||
|
|
25d5097edb | ||
|
|
d3e3b4764a | ||
|
|
33a557c761 | ||
|
|
32de2819df | ||
|
|
a21d252e9b | ||
|
|
84a766db0c | ||
|
|
0581dc3485 | ||
|
|
3d6c07bd91 | ||
|
|
60ae1fb5c3 | ||
|
|
1fafebb16d | ||
|
|
a9e09c38e9 | ||
|
|
3d4236e8df | ||
|
|
16becd5340 | ||
|
|
1397380fe9 | ||
|
|
4ffc99b3fe | ||
|
|
81ce5188ea | ||
|
|
4e0c21d86c | ||
|
|
df69b3f05d | ||
|
|
eee332412f | ||
|
|
f750f39b50 | ||
|
|
f1d90b6097 | ||
|
|
7f4196124d | ||
|
|
5658726ea5 | ||
|
|
f5d5690401 | ||
|
|
0aa893ab7d | ||
|
|
6f20b0f146 | ||
|
|
80248d4b7a | ||
|
|
20e991062c | ||
|
|
b784d6d796 | ||
|
|
00e8d68ce5 | ||
|
|
2a8f6d9062 | ||
|
|
9b3134d767 | ||
|
|
36363fa3db | ||
|
|
d133cc3271 | ||
|
|
5a70a685b4 | ||
|
|
100b62800c | ||
|
|
1ae19074ee | ||
|
|
d68f6b653a | ||
|
|
29baba3a72 | ||
|
|
217ecc1aa1 | ||
|
|
0cb0b82fb1 | ||
|
|
95f2903067 | ||
|
|
d78d7a0181 | ||
|
|
ff8a9c50ae | ||
|
|
6bd6ddcdca | ||
|
|
b38616e051 | ||
|
|
479f4719ba | ||
|
|
2825250804 | ||
|
|
aa280c48b7 | ||
|
|
4cf5b87f2b | ||
|
|
0dff7770a1 | ||
|
|
e3dd6a3427 | ||
|
|
55fdcfaae3 | ||
|
|
cf1ec25c71 | ||
|
|
0ace758c79 | ||
|
|
c89288191e | ||
|
|
4655125541 | ||
|
|
ba60448d05 | ||
|
|
1d809b2c95 | ||
|
|
38bda66933 | ||
|
|
726a8e116b |
No files matched your search
+3
-3
@@ -84,9 +84,9 @@ BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (d
|
||||
|
||||
# === AI Analysis ===
|
||||
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_BASE_URL=http://100.121.180.82:20128/api/v1 # LLM API base URL (omniroute on imrnes; /api/v1 exposes OpenAI-compatible chat+embeddings)
|
||||
AI_LLM_MODEL=text # LLM text model name (default: text)
|
||||
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_MODEL=claude-opus-5 # LLM text model name (default: claude-opus-5)
|
||||
# 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_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
# GMW — Moderation Explainability (#1) + Semantic Search (#3) Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development to implement task-by-task.
|
||||
> Hard constraint from user (2026-08-18): web is PUBLIC, read-only, for USERS not admins. Moderation MUST stay FULLY AUTOMATIC. Rules stay in CODE (no per-channel config UI).
|
||||
|
||||
**Goal:** Make GMW transparent (users see why a message was moderated) and searchable (users can semantic-search the message corpus), via two fully-automatic, code-driven, read-only-public features.
|
||||
|
||||
**Architecture:**
|
||||
- **#1 Explainability:** Persist the structured moderation verdict that already exists in `AnalysisResult` (`flags[]`, `categories[]`, `severity`, `confidence`, `evidence[]`) into new columns on `moderation_actions`, surface them through the existing public moderation oRPC + the existing public `moderation` dashboard view. No new behavior — only new *data* + new *read* paths.
|
||||
- **#3 Semantic Search:** Add a SECOND persistent Qdrant collection (`gmw_message_archive`) keyed by message id (NOT the TTL cache). Embed each captured text message at capture time (reuse `embedText`) and upsert. Add a public `messages.semanticSearch` oRPC + a read-only search UI on the public `messages` view. Best-effort / non-blocking — embed failures never affect moderation or capture.
|
||||
|
||||
**Tech Stack:** TypeScript (discord-gateway + backend + frontend monorepo), Drizzle ORM + Postgres (PgBouncer on imrnes), Qdrant (100.121.180.82:6333), Next.js 16 App Router + shadcn/ui, oRPC over `/trpc`. pnpm. Deploy via GitHub Actions Nix build + `systemctl restart`.
|
||||
|
||||
**Critical existing facts (verified in repo):**
|
||||
- `AnalysisResult` shape (`src/modules/ai-moderation/ai-analysis-worker.ts:55`): `messageId, status, flags[], categories[], severity, confidence, recommendedAction, score, analysis, correctedFlags?`. The shared `AnalysisResult` (`src/shared/moderation-types.ts:142`) ALSO has `evidence?: string[]` and `policyVersion?: string`. **THESE ARE ALREADY COMPUTED but only logged, never persisted to `moderation_actions`.**
|
||||
- `moderation_actions` schema is DEFINED TWICE with a divergence:
|
||||
- `src/shared/database/schema.ts:638` → `pgModerationActionsTable` (authoritative, has `reset_nickname` in `action_type` enum).
|
||||
- `src/shared/database/schema/messages.ts:25` → another `pgModerationActionsTable` (NO `reset_nickname`; gateway-local copy).
|
||||
- The gateway's `ModerationActionsDb` (`src/modules/message-capture/moderationActionsDb.ts`) imports from `schema.ts` (the authoritative one). The `messages.ts` copy appears UNUSED for DB ops — but WE MUST ADD NEW COLUMNS TO BOTH to avoid type drift, OR confirm the `messages.ts` copy is dead and delete it. **Decision: add columns to `schema.ts` (authoritative) AND the `messages.ts` copy to keep `$inferInsert`/`$inferSelect` in sync (the gateway `ModerationAction` type flows from shared).** Line truncated
|
||||
- Migration mechanism: Drizzle-managed via `drizzle/migrations/*.sql` (journal `_journal.json`) applied by `runMigrations()` → `migratePostgres`. **New tables/columns must be added with `drizzle-kit generate` to produce a numbered `.sql` + journal entry**, OR (simpler, matches `0013_rename_*.sql` manual style) write a raw idempotent `.sql` under `drizzle/migrations/` AND register it in `_journal.json`. **Preferred here: use `pnpm drizzle-kit generate` so the journal stays consistent.** The leLine truncated
|
||||
- **Historical lesson (MUST respect):** a prior migration (`0004_drop_unused_ai_columns.sql` = old `001_drop`) DELETED `ai_evidence`, `ai_policy_version`, `ai_moderation_raw` from `messages` with the note "written but never read". → Our new `moderation_actions` columns MUST be read (serializer + FE render). No write-only columns.
|
||||
- Embedding client: `embedText(text)` / `embedTexts(texts[])` in `src/modules/ai-moderation/embeddingClient.ts`. Returns `null` if `AI_LLM_EMBEDDING_MODEL` not configured. Reuses `config.AI_LLM_BASE_URL` + `config.AI_LLM_API_KEY`. OpenAI SDK v6 → `encoding_format: "float"` REQUIRED (Nvidia rejects base64).
|
||||
- Qdrant client: `src/modules/ai-moderation/qdrantClient.ts`. Has `ensureQdrantCollection(vectorSize)`, `upsertQdrantPoint(cacheKey, vector, payload)`, `searchQdrant(vector, limit, scoreThreshold)`. These are hardcoded to the cache collection name `config.QDRANT_COLLECTION ?? "gmw_text_moderation"`. **#3 needs a second collection** → generalize the client to accept a collection name param (add `ensureQdrantCollectionV2(name, size)` / `upsertQdrantPointV2(name, id, vector, payload)` / `searchQLine truncated
|
||||
- Capture hook: `captureMessage()` (`src/modules/message-capture/messageCapture.ts:201`) calls `messageStore.upsertMessageForCapture(messageRecord)` then (if not backlog) `queueMessageAnalysis`. **#3 embed must happen here**, async + fire-and-forget, after successful insert.
|
||||
- Public moderation view: `services/frontend/src/app/(dashboard)/moderation/view.tsx` renders `ActionRow` per action. The `ModerationAction` FE type is in `services/frontend/src/lib/types/moderation.ts` (NO new fields yet). The backend `moderationService.listActions` SQL is in `services/backend/src/modules/moderation/moderation.repository.ts:68` (raw SQL, selects fixed columns, joins `messages`).
|
||||
- oRPC wiring: `services/backend/src/orpc/router.ts` → `moderationRouter` (stats, actions) and `messagesRouter` (list, byChannel, getById, review, attachments). New procedures added here.
|
||||
|
||||
---
|
||||
|
||||
## TASK 1 — Schema: add explainability columns to `moderation_actions`
|
||||
|
||||
**Objective:** Persist structured verdict on moderation actions so it can be surfaced (read) later.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/discord-gateway/src/shared/database/schema.ts` (authoritative `pgModerationActionsTable`, ~line 638)
|
||||
- Modify: `services/discord-gateway/src/shared/database/schema/messages.ts` (`pgModerationActionsTable` copy, ~line 25) to keep type in sync
|
||||
- Create: `services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql`
|
||||
- Update: `services/discord-gateway/drizzle/migrations/meta/_journal.json` (add new entry)
|
||||
|
||||
**Step 1: Add columns to both schema definitions**
|
||||
Add after `executed_at` in BOTH `pgModerationActionsTable` definitions:
|
||||
```ts
|
||||
// ── Explainability (structured verdict, surfaced read-only to public web) ──
|
||||
flags: pgText("flags"), // JSON array of string flags, e.g. ["sara_agama","vulgar"]
|
||||
categories: pgText("categories"), // JSON array of category strings
|
||||
severity: pgText("severity", {
|
||||
enum: ["none", "low", "medium", "high", "critical"],
|
||||
}),
|
||||
confidence: pgReal("confidence"), // 0..1
|
||||
score: pgReal("score"), // 0..1 raw model score
|
||||
evidence: pgText("evidence"), // JSON array of short quoted snippets
|
||||
policy_version: pgText("policy_version"), // rules.ts policy version string
|
||||
```
|
||||
Note: `flags`/`categories`/`evidence` stored as JSON-stringified TEXT (consistent with how `messages.ai_moderation_flags`/`ai_categories` are stored as TEXT elsewhere — confirm storage format in `updateMessageAIAnalysis`). Keep nullable.
|
||||
|
||||
**Step 2: Generate/author the migration SQL**
|
||||
`0015_add_moderation_explainability.sql` (idempotent):
|
||||
```sql
|
||||
-- Add structured explainability columns to moderation_actions (read-only surfaced to public web).
|
||||
ALTER TABLE IF EXISTS "moderation_actions"
|
||||
ADD COLUMN IF NOT EXISTS "flags" text,
|
||||
ADD COLUMN IF NOT EXISTS "categories" text,
|
||||
ADD COLUMN IF NOT EXISTS "severity" text
|
||||
CHECK ("severity" IS NULL OR "severity" IN ('none','low','medium','high','critical')),
|
||||
ADD COLUMN IF NOT EXISTS "confidence" real,
|
||||
ADD COLUMN IF NOT EXISTS "score" real,
|
||||
ADD COLUMN IF NOT EXISTS "evidence" text,
|
||||
ADD COLUMN IF NOT EXISTS "policy_version" text;
|
||||
```
|
||||
Register in `_journal.json`: append an entry with `idx: 15`, a new unique `tag` (hash), `version`, `when` = Date.now(), `tag` short, `breakpoints: false`. Use `pnpm drizzle-kit generate` if possible to get a correct tag; otherwise hand-edit the journal carefully (copy an existing entry's shape).
|
||||
|
||||
**Step 3: Type-check gateway**
|
||||
Run: `cd services/discord-gateway && pnpm typecheck`
|
||||
Expected: PASS (no new compile errors).
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add services/discord-gateway/src/shared/database/schema.ts \
|
||||
services/discord-gateway/src/shared/database/schema/messages.ts \
|
||||
services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql \
|
||||
services/discord-gateway/drizzle/migrations/meta/_journal.json
|
||||
git commit -m "feat(db): add explainability columns to moderation_actions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TASK 2 — Persist verdict at the auto-delete + command-handler call sites
|
||||
|
||||
**Objective:** Populate the new columns from the already-computed `AnalysisResult` when a moderation action is logged. Fully automatic, no new behavior.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts` (`logAutoDeleteAttempt` ~line 166, and the second `createModerationAction` call ~line 234 for nickname/mute paths)
|
||||
- Modify: `services/discord-gateway/src/modules/command-handler/moderation.handler.ts` (`createModerationAction` ~line 95)
|
||||
- Helper (create): `services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts` — shared mapper so all 3 call sites stay DRY.
|
||||
|
||||
**Step 1: Create the mapper helper**
|
||||
`verdictToActionFields.ts`:
|
||||
```ts
|
||||
import type { AnalysisResult } from "@/modules/ai-moderation/ai-analysis-worker";
|
||||
import type { ModerationActionInsert } from "@/shared/index"; // or inline shape
|
||||
|
||||
/**
|
||||
* Map a computed AI verdict into the explainability columns of a moderation
|
||||
* action. Null-safe: missing fields stay null (e.g. manual admin actions have
|
||||
* no AnalysisResult). This is read-only structured data — it does NOT change
|
||||
* any enforcement decision.
|
||||
*/
|
||||
export function verdictToActionFields(result?: {
|
||||
flags?: string[];
|
||||
categories?: string[];
|
||||
severity?: string;
|
||||
confidence?: number;
|
||||
score?: number;
|
||||
evidence?: string[];
|
||||
policyVersion?: string;
|
||||
}): {
|
||||
flags: string | null;
|
||||
categories: string | null;
|
||||
severity: string | null;
|
||||
confidence: number | null;
|
||||
score: number | null;
|
||||
evidence: string | null;
|
||||
policy_version: string | null;
|
||||
} {
|
||||
if (!result) {
|
||||
return { flags: null, categories: null, severity: null, confidence: null,
|
||||
score: null, evidence: null, policy_version: null };
|
||||
}
|
||||
const j = (v: unknown) => (v == null ? null : JSON.stringify(v));
|
||||
return {
|
||||
flags: j(result.flags),
|
||||
categories: j(result.categories),
|
||||
severity: result.severity ?? null,
|
||||
confidence: result.confidence ?? null,
|
||||
score: result.score ?? null,
|
||||
evidence: j(result.evidence),
|
||||
policy_version: result.policyVersion ?? null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Wire `logAutoDeleteAttempt`**
|
||||
Find the `createModerationAction({...})` in `logAutoDeleteAttempt` and spread the verdict fields:
|
||||
```ts
|
||||
await messageStore.createModerationAction({
|
||||
message_id: message.id,
|
||||
user_id: message.user_id,
|
||||
guild_id: message.guild_id,
|
||||
action_type: "delete_message",
|
||||
reason: result.reason,
|
||||
...verdictToActionFields(result.analysisResult), // <-- pass the AnalysisResult through
|
||||
executed_by: "auto-delete-manager",
|
||||
status: ...,
|
||||
});
|
||||
```
|
||||
**IMPORTANT:** `result` here is `AutoDeleteResult` — verify it carries the `AnalysisResult` (or the verdict). If `AutoDeleteResult` does NOT carry the full `AnalysisResult`, trace where `attemptAutoDeleteFlaggedMessage` is called from and pass the `AnalysisResult` down (it is available in the analysis worker that triggered the delete). Confirm by reading `AutoDeleteResult` type + its producer. If the verdict is only available at the orchestrator level, add an optional `verdict?: AnalysisResulLine truncated
|
||||
|
||||
**Step 3: Wire the second call site in `autoDeleteManager.ts`** (the mute/nickname path ~line 234) similarly, if it has an `AnalysisResult` available; otherwise leave fields null (manual-style action).
|
||||
|
||||
**Step 4: Wire `moderation.handler.ts`** command path (~line 95) — pass `verdictToActionFields(verdict)` if the command handler has the `AnalysisResult` for the target message; otherwise nulls. Confirm what the handler receives.
|
||||
|
||||
**Step 5: Type-check + lint**
|
||||
Run: `cd services/discord-gateway && pnpm typecheck && pnpm lint`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 6: Commit**
|
||||
```bash
|
||||
git add services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts \
|
||||
services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts \
|
||||
services/discord-gateway/src/modules/command-handler/moderation.handler.ts
|
||||
git commit -m "feat(mods): persist structured verdict into moderation_actions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TASK 3 — Backend: surface explainability in `moderation.actions`
|
||||
|
||||
**Objective:** Read the new columns in the public oRPC so the frontend can render them. (Read path — satisfies the "never write-only" rule.)
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/backend/src/modules/moderation/moderation.repository.ts` (`listActions` raw SQL ~line 68) — add new columns to SELECT + map.
|
||||
- Modify: `services/frontend/src/lib/types/moderation.ts` (`ModerationAction` interface) — add new fields.
|
||||
- Modify: `services/frontend/src/app/(dashboard)/moderation/view.tsx` (`ActionRow`) — render flags/categories badges + severity + confidence + evidence snippet.
|
||||
|
||||
**Step 1: Extend backend SELECT**
|
||||
In `listActions`, add to the SELECT list: `a.flags, a.categories, a.severity, a.confidence, a.score, a.evidence, a.policy_version`. In the `.map(...)` add:
|
||||
```ts
|
||||
flags: r.flags ? safeJsonArray(String(r.flags)) : null,
|
||||
categories: r.categories ? safeJsonArray(String(r.categories)) : null,
|
||||
severity: r.severity ? String(r.severity) : null,
|
||||
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||
score: r.score != null ? Number(r.score) : null,
|
||||
evidence: r.evidence ? safeJsonArray(String(r.evidence)) : null,
|
||||
policy_version: r.policy_version ? String(r.policy_version) : null,
|
||||
```
|
||||
where `safeJsonArray(s)` = `JSON.parse(s)` wrapped in try/catch returning `[]` on failure (define a tiny local helper in the repository file).
|
||||
|
||||
**Step 2: Extend FE type**
|
||||
In `services/frontend/src/lib/types/moderation.ts` `ModerationAction`:
|
||||
```ts
|
||||
flags: string[] | null;
|
||||
categories: string[] | null;
|
||||
severity: "none" | "low" | "medium" | "high" | "critical" | null;
|
||||
confidence: number | null;
|
||||
score: number | null;
|
||||
evidence: string[] | null;
|
||||
policy_version: string | null;
|
||||
```
|
||||
|
||||
**Step 3: Render in `ActionRow`**
|
||||
After the existing reason block, add (using existing `Badge` + `aiTone` from `@/lib/ai-status`):
|
||||
```tsx
|
||||
{a.severity && (
|
||||
<Badge tone={aiTone(a.severity === "none" ? "clean" : a.severity)}>
|
||||
{a.severity}
|
||||
</Badge>
|
||||
)}
|
||||
{a.flags?.length ? (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{a.flags.map((f) => <Badge key={f} tone="amber">{f}</Badge>)}
|
||||
</div>
|
||||
) : null}
|
||||
{a.evidence?.length ? (
|
||||
<div className="mt-1 text-xs text-ink-faint border-l-2 border-hairline pl-2">
|
||||
“{a.evidence[0]}”
|
||||
</div>
|
||||
) : null}
|
||||
{a.confidence != null && (
|
||||
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
|
||||
conf {(a.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
Keep `ActionRow` read-only. No admin controls.
|
||||
|
||||
**Step 4: Type-check both services**
|
||||
Run: `cd services/backend && pnpm typecheck && pnpm lint` and `cd services/frontend && pnpm typecheck && pnpm lint`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add services/backend/src/modules/moderation/moderation.repository.ts \
|
||||
services/frontend/src/lib/types/moderation.ts \
|
||||
services/frontend/src/app/\(dashboard\)/moderation/view.tsx
|
||||
git commit -m "feat(web): surface moderation explainability (flags/severity/evidence)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TASK 4 — Qdrant client: support a second persistent collection
|
||||
|
||||
**Objective:** Generalize the Qdrant client so #3 can use a dedicated archive collection without disturbing the automod cache.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts`
|
||||
|
||||
**Step 1: Add collection-aware variants**
|
||||
Refactor `collectionName()` to accept an optional name, and add V2 functions that take an explicit collection:
|
||||
```ts
|
||||
function collectionName(fallback = config.QDRANT_COLLECTION ?? "gmw_text_moderation"): string {
|
||||
return fallback;
|
||||
}
|
||||
export const ARCHIVE_COLLECTION = config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
|
||||
|
||||
export async function ensureQdrantCollectionV2(name: string, vectorSize: number): Promise<boolean> {
|
||||
// same body as ensureQdrantCollection but uses `name` instead of collectionName()
|
||||
}
|
||||
export async function upsertQdrantPointV2(
|
||||
name: string, pointId: number, vector: number[], payload: QdrantVerdictPayload,
|
||||
): Promise<boolean> { /* PUT /collections/{name}/points with wait:true */ }
|
||||
export async function searchQdrantV2(
|
||||
name: string, vector: number[], limit: number, scoreThreshold: number,
|
||||
): Promise<QdrantSearchHit[]> { /* POST /collections/{name}/points/search */ }
|
||||
```
|
||||
Keep all existing `ensureQdrantCollection` / `upsertQdrantPoint` / `searchQdrant` UNCHANGED (cache path). V2 functions mirror them with the `name` param. Reuse `request()` and the existing payload/score types.
|
||||
|
||||
**Step 2: Type-check**
|
||||
Run: `cd services/discord-gateway && pnpm typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts
|
||||
git commit -m "feat(qdrant): add collection-aware V2 upsert/search for archive"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TASK 5 — Capture-time embed + archive upsert
|
||||
|
||||
**Objective:** Make every (non-backlog, text) captured message searchable in the persistent archive. Non-blocking / best-effort.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/discord-gateway/src/modules/message-capture/messageCapture.ts` (`captureMessage` ~line 201)
|
||||
- Create: `services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts` — wraps embed + upsert with fire-and-forget + rate-limit guard.
|
||||
|
||||
**Step 1: Create `archiveEmbedder.ts`**
|
||||
```ts
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { embedText } from "@/modules/ai-moderation/embeddingClient";
|
||||
import { ARCHIVE_COLLECTION, ensureQdrantCollectionV2, upsertQdrantPointV2, qdrantPointId } from "@/modules/ai-moderation/qdrantClient";
|
||||
import { config } from "@/shared/config/config";
|
||||
|
||||
const log = createChildLogger("archive-embedder");
|
||||
|
||||
/**
|
||||
* Fire-and-forget: embed a captured message and upsert into the persistent
|
||||
* archive collection. Failures are swallowed — searching is a nice-to-have,
|
||||
* never a precondition for capture or moderation.
|
||||
*/
|
||||
export function archiveMessageEmbedded(message: {
|
||||
id: string; content: string; username: string; channel_id: string; guild_id: string; created_at: number;
|
||||
}): void {
|
||||
if (!config.AI_LLM_EMBEDDING_MODEL) return; // embeddings disabled → skip
|
||||
if (!message.content || message.content.trim().length < 3) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const vector = await embedText(message.content);
|
||||
if (!vector) return;
|
||||
const ok = await ensureQdrantCollectionV2(ARCHIVE_COLLECTION, vector.length);
|
||||
if (!ok) return;
|
||||
await upsertQdrantPointV2(ARCHIVE_COLLECTION, qdrantPointId(`archive:${message.id}`), vector, {
|
||||
text: message.content.slice(0, 4000),
|
||||
flags: "", // not a verdict payload; keep shape compatible
|
||||
analyzed_at: Date.now(),
|
||||
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 365 * 5, // 5y persistent
|
||||
content_hash: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
log.debug({ messageId: message.id, error: err instanceof Error ? err.message : String(err) }, "archive embed skipped");
|
||||
}
|
||||
})();
|
||||
}
|
||||
```
|
||||
Payload type reuse: `QdrantVerdictPayload` has `text`, `flags`, `analyzed_at`, `expires_at`, `content_hash?`. For the archive we only need `text` + timestamps; set `flags: ""` (empty, ignored by search filter which keys on `expires_at`). **Acceptable:** the search path filters `expires_at >= now` — 5y window satisfies that.
|
||||
|
||||
**Step 2: Call from `captureMessage`**
|
||||
In `captureMessage`, after `const inserted = await messageStore.upsertMessageForCapture(messageRecord); if (!inserted) return;` and BEFORE the backlog branch, add:
|
||||
```ts
|
||||
if (!isBacklog && messageRecord.content) {
|
||||
archiveMessageEmbedded(messageRecord);
|
||||
}
|
||||
```
|
||||
(`messageRecord` is the `MessageRecord` from `buildMessageRecord`; confirm it carries `content`, `channel_id`, `guild_id`, `created_at`. It does — see `messagesCrud`/`types`.)
|
||||
|
||||
**Step 3: Type-check + lint**
|
||||
Run: `cd services/discord-gateway && pnpm typecheck && pnpm lint`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts \
|
||||
services/discord-gateway/src/modules/message-capture/messageCapture.ts
|
||||
git commit -m "feat(archive): embed captured messages into persistent Qdrant archive"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TASK 6 — Backend: `messages.semanticSearch` oRPC
|
||||
|
||||
**Objective:** Public, read-only semantic search over the message archive.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/backend/src/modules/messages/messages.repository.ts` — add `semanticSearch(query, limit, guildId?)`.
|
||||
- Modify: `services/backend/src/modules/messages/messages.service.ts` — expose `semanticSearch`.
|
||||
- Modify: `services/backend/src/orpc/router.ts` — add `messages.semanticSearch` procedure.
|
||||
- Create (or reuse): an embedding call from the backend. The backend does NOT import the gateway's `embeddingClient`. **Decision:** add a minimal backend embed helper `services/backend/src/modules/messages/embed.ts` that calls the same OpenAI-compatible endpoint via `config` (reuse `config.AI_LLM_BASE_URL`/`AI_LLM_API_KEY`/`AI_LLM_EMBEDDING_MODEL` if present on the backend; if not configured, return a clear "search unavailable" error). Mirror `encoding_format: "float"`.
|
||||
- Modify: `services/backend/src/modules/messages/messages.schema.ts` — add `semanticSearchQuery` zod schema (limit, guildId?, query).
|
||||
|
||||
**Step 1: Backend embed helper** (`embed.ts`)
|
||||
```ts
|
||||
import OpenAI from "openai";
|
||||
import { config } from "@/shared/config/index";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
const log = createChildLogger("messages-embed");
|
||||
let client: OpenAI | null = null;
|
||||
function getClient() {
|
||||
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
|
||||
if (!client) client = new OpenAI({ apiKey: config.AI_LLM_API_KEY, baseURL: config.AI_LLM_BASE_URL, maxRetries: 0, timeout: 30_000 });
|
||||
return client;
|
||||
}
|
||||
export async function embedQuery(text: string): Promise<number[] | null> {
|
||||
const c = getClient(); if (!c) return null;
|
||||
try {
|
||||
const r = await c.embeddings.create({ model: config.AI_LLM_EMBEDDING_MODEL as string, input: text, encoding_format: "float" });
|
||||
return r.data[0].embedding;
|
||||
} catch (e) { log.warn({ error: e instanceof Error ? e.message : String(e) }, "query embed failed"); return null; }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Repository `semanticSearch`**
|
||||
```ts
|
||||
async semanticSearch(queryVector: number[], limit: number, guildId?: string) {
|
||||
// Search archive collection, then join messages for text + channel.
|
||||
const hits = await searchQdrantV2(ARCHIVE_COLLECTION, queryVector, limit, 0.6);
|
||||
const ids = hits.map(h => h.cacheKey.replace("qdrant:", "")); // point id → we stored archive:<messageId>
|
||||
// decode: qdrantPointId is a uint64; we need the original message id.
|
||||
// SIMPLER: store message_id inside the payload too. → update archiveEmbedder payload to include `message_id`.
|
||||
...
|
||||
}
|
||||
```
|
||||
**REFINEMENT (important):** `qdrantPointId` is a hash, not reversible. So the archive payload MUST carry `message_id` (and `channel_id`, `guild_id`, `username`, `created_at`) so the backend can return full results without a reverse lookup. **Update `archiveEmbedder.ts` payload** to include those fields, and relax `QdrantVerdictPayload` (or create `QdrantArchivePayload`) to allow them. Then `semanticSearch` returns the payloads directly (already contain text + metadata) — no DB join needed, anLine truncated
|
||||
|
||||
**Step 3: Service + router**
|
||||
`messages.service.ts`: `async semanticSearch(query: string, limit: number, guildId?: string)` → embed → `repository.semanticSearch`.
|
||||
`orpc/router.ts` under `messagesRouter`:
|
||||
```ts
|
||||
semanticSearch: os
|
||||
.input(z.object({ query: z.string().min(1), limit: z.coerce.number().int().positive().max(50).default(10), guildId: z.string().optional() }))
|
||||
.handler(async ({ input }) => {
|
||||
const results = await messagesService.semanticSearch(input.query, input.limit, input.guildId);
|
||||
return { results, nextCursor: null };
|
||||
}),
|
||||
```
|
||||
|
||||
**Step 4: Type-check + lint (backend)**
|
||||
Run: `cd services/backend && pnpm typecheck && pnpm lint`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add services/backend/src/modules/messages/embed.ts \
|
||||
services/backend/src/modules/messages/messages.repository.ts \
|
||||
services/backend/src/modules/messages/messages.service.ts \
|
||||
services/backend/src/modules/messages/messages.schema.ts \
|
||||
services/backend/src/orpc/router.ts
|
||||
git commit -m "feat(api): public semantic message search over archive"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TASK 7 — Frontend: semantic search UI on `messages` view
|
||||
|
||||
**Objective:** Public, read-only search box + results on the existing messages dashboard.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/frontend/src/app/(dashboard)/messages/page.tsx` + `view.tsx` — add a search input (debounced) that calls a new `useMessagesSemanticSearch` hook → `messages.semanticSearch` oRPC, renders results as message cards (reuse `GlassPanel`/`Badge`/existing message row components).
|
||||
- Modify: `services/frontend/src/lib/types/message.ts` — add `SemanticSearchResult` + `SemanticSearchResponse` types.
|
||||
- Modify: `services/frontend/src/lib/api/client.ts` (or `server.ts`) — add `semanticSearch` fetcher/routers export if using oRPC client; if the FE uses raw fetch through the proxy, add a `POST /api/messages/semantic-search` or an oRPC client call consistent with existing `messages.*` calls (follow the EXISTING pattern in `src/lib/api/` — inspect how `messages.list` is called and replicate).
|
||||
|
||||
**Step 1: Add FE types**
|
||||
```ts
|
||||
export interface SemanticSearchResult {
|
||||
message_id: string;
|
||||
content: string;
|
||||
username: string;
|
||||
channel_id: string;
|
||||
guild_id: string;
|
||||
created_at: number;
|
||||
score: number;
|
||||
}
|
||||
export interface SemanticSearchResponse { results: SemanticSearchResult[]; nextCursor: string | null; }
|
||||
```
|
||||
|
||||
**Step 2: Add hook + wire view**
|
||||
Follow the existing `use-moderation.ts` SWR pattern. Add `useMessagesSemanticSearch(query, guildId?)` returning `{ data, isLoading, error }`. In `messages/view.tsx`, add a search `Input` (from `@/components/primitives`) at the top, debounce ~300ms, and render results below the live list when a query is present. Reuse the message-row rendering already in that view (do not invent a new component).
|
||||
|
||||
**Step 3: Type-check + lint (frontend)**
|
||||
Run: `cd services/frontend && pnpm typecheck && pnpm lint`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add services/frontend/src/app/\(dashboard\)/messages/ \
|
||||
services/frontend/src/lib/types/message.ts \
|
||||
services/frontend/src/lib/api/ \
|
||||
services/frontend/src/hooks/
|
||||
git commit -m "feat(web): public semantic message search UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TASK 8 — Build all services + deploy + verify
|
||||
|
||||
1. `cd services/discord-gateway && pnpm typecheck && pnpm build && pnpm lint`
|
||||
2. `cd services/backend && pnpm typecheck && pnpm build && pnpm lint`
|
||||
3. `cd services/frontend && pnpm typecheck && pnpm build && pnpm lint`
|
||||
4. Commit any formatting fixes (biome `--unsafe` if import order), author `asepharyana`, NO Co-Authored-By.
|
||||
5. `git push origin main` → watch `gh run watch` on "Build & Deploy (Nix)".
|
||||
6. After deploy: verify `systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp,SubState` reflects new timestamp; same for backend + frontend.
|
||||
7. Smoke: `curl -s http://127.0.0.1:4001/trpc/messages.semanticSearch?input=<urlencoded json>` OR via the public web `imphnen.asepharyana.my.id` messages page → type a query → expect results (after some messages have been embedded; embeddings only run on NEW captures post-deploy, so seed a few test messages or backfill).
|
||||
8. Moderation explainability: trigger/observe a flagged message → confirm `moderation_actions.flags` is populated (SQL `SELECT flags, severity FROM moderation_actions ORDER BY created_at DESC LIMIT 5;`) and the public moderation view shows badges.
|
||||
|
||||
---
|
||||
|
||||
## RISKS / TRADEOFFS / OPEN QUESTIONS
|
||||
- **Embedding cost:** #3 embeds EVERY captured message → more embedding API calls. Mitigated: only text ≥3 chars, fire-and-forget, skip if model unconfigured. If cost is a concern, batch embed (reuse `embedTexts`) per capture burst — but start simple (per-message) and observe.
|
||||
- **Backfill:** post-deploy, the archive is empty until new messages arrive. Optional follow-up: a one-off backfill script over existing `messages` (out of scope for this plan unless user asks).
|
||||
- **Schema duplication:** the `pgModerationActionsTable` double-definition must stay in sync (Task 1 patches both). If `messages.ts` copy is provably dead, a follow-up can delete it — but NOT in this plan (avoid scope creep / risk).
|
||||
- **`AutoDeleteResult` verdict availability (Task 2):** requires confirming the `AnalysisResult` is reachable at the `createModerationAction` call sites. If not, we add an optional field to `AutoDeleteResult` at the orchestrator call site. This is the highest-risk integration point — verify before assuming.
|
||||
- **Public exposure:** semantic search returns message text + usernames. This is INTENDED (web is public for users). No auth added. If a guild wants private, that is a future config (out of scope).
|
||||
- **Qdrant payload type:** reusing `QdrantVerdictPayload` for archive is slightly awkward (carries `flags`/`expires_at` semantics). Cleaner: introduce `QdrantArchivePayload` with `message_id`, `channel_id`, `guild_id`, `username`, `content`, `created_at`, `expires_at`. **Prefer the dedicated payload type in Task 4/5** to avoid confusion.
|
||||
|
||||
## VERIFICATION CHECKLIST
|
||||
- [ ] `moderation_actions` has 7 new columns (DB + both schema defs).
|
||||
- [ ] A real auto-delete populates `flags`/`severity`/`evidence` (verified via SQL).
|
||||
- [ ] Public moderation view renders badges + evidence (manual browser check on imphnen.asepharyana.my.id/moderation).
|
||||
- [ ] New message capture upserts a point into `gmw_message_archive` (verify via Qdrant `/collections/gmw_message_archive/points/count`).
|
||||
- [ ] `messages.semanticSearch` returns relevant results for a known phrase.
|
||||
- [ ] All three services: typecheck + build + lint green; CI "Build & Deploy (Nix)" green; systemd timestamps updated.
|
||||
@@ -0,0 +1,101 @@
|
||||
# GMW — Fitur Publik Lanjutan (#2–#6) Implementation Plan
|
||||
|
||||
> **For Hermes:** Implement task-by-task. Build + lint + typecheck each service
|
||||
> after its changes. Deploy via push to main (CI handles Nix build + systemd).
|
||||
> Hard constraint (user 2026-08-18): public read-only web, fully automatic,
|
||||
> rules in code, NO admin endpoints, NO shadow mode, NO per-channel web config.
|
||||
> **EXPLICITLY EXCLUDED: User Reputation / Strike History** (user: "hapus
|
||||
> sepenuhnya fitur user reputation" — it was never built; do not add it).
|
||||
|
||||
## Existing infra to reuse (verified)
|
||||
- **WS**: backend `ws/server.ts` broadcasts JSON `{type,data,timestamp}` to
|
||||
frontendClients. Backend `ws/redis-bridge.ts` subscribes Redis channels
|
||||
listed in `DISCORD_CHANNEL_TO_WS_EVENT` (backend `shared/redis-channels.ts`)
|
||||
and re-emits as WS events. FE `src/lib/ws` auto-reconnect typed client.
|
||||
- **Gateway → Redis**: `EventBroadcaster` + `RedisEventPublisher` (
|
||||
`discord-gateway/src/modules/event-broadcaster`). Publish via
|
||||
`eventBroadcaster.publish(EventChannels.X, payload)`.
|
||||
- **Moderation data**: `moderation_actions` table (now has explainability
|
||||
cols). `moderation.repository.listActions` returns rows. `ModerationAction`
|
||||
FE type at `frontend/src/lib/types/moderation.ts`.
|
||||
- **Messages**: `messages.list` / `getMessagesByChannel` (backend oRPC +
|
||||
repository). FE `messagesApi` + `useMessages`.
|
||||
- **Charts**: NO chart lib installed. Use **pure SVG/CSS** (consistent with
|
||||
repo; avoid new deps).
|
||||
- **CSV**: client-side Blob download, no backend.
|
||||
|
||||
## Task 1 — Live Moderation Feed (#2)
|
||||
**Gateway**: add `MODERATION_ACTION: "discord:moderation:action"` to
|
||||
`redis-channels.ts` (shared) + `EventChannels.MODERATION_ACTION` in
|
||||
`eventTypes.ts`. In `moderationActionsDb.createModerationAction`, after insert,
|
||||
publish `eventBroadcaster.publish(EventChannels.MODERATION_ACTION, actionRow)`.
|
||||
**Backend**: add `DISCORD_MODERATION_ACTION` constant + map
|
||||
`[DISCORD_MODERATION_ACTION]: "moderation_action"` in `DISCORD_CHANNEL_TO_WS_EVENT`.
|
||||
**FE**: in `src/lib/ws`, subscribe to `moderation_action`; add `useLiveModeration`
|
||||
hook (SWR-style with WS push, capped buffer ~50). Add `<LiveModerationFeed>`
|
||||
client component on `/moderation` page (top of list, animated new-row).
|
||||
Risk: gateway publish at every action (already async insert) — fire-and-forget,
|
||||
wrap in try/catch. Verify WS event reaches FE via `wscat`/curl or log.
|
||||
|
||||
## Task 2 — Toxic Topic Trends (#3)
|
||||
**Backend**: add `moderation.trends` oRPC. Query `moderation_actions` grouped
|
||||
by `categories` (jsonb text[]) over last 30 days, count per category + severity
|
||||
breakdown. Also `action_type` distribution. Return
|
||||
`{ categories: {name,count}[], severities: {level,count}[], actions: {type,count}[] }`.
|
||||
Map jsonb array in SQL (use `unnest` or parse in JS). Reuse `getDatabase`.
|
||||
**FE**: `useModerationTrends` hook + `<TopicTrends>` SVG bar chart (top 10
|
||||
categories) + severity donut (SVG arcs). Place on `/moderation` as a panel.
|
||||
|
||||
## Task 3 — Channel Timeline / Replay (#4)
|
||||
Reuse existing `messages.list` (guildId) + `getMessagesByChannel`. Add a
|
||||
**Timeline tab** to `/messages` that groups messages by date (client-side
|
||||
bucket from `created_at`). Load-more via cursor. No new backend (existing
|
||||
`messagesRouter.list` already supports guildId+limit+cursor). If needed, add
|
||||
`messages.timeline` aggregation (count per day) — but keep simple: client
|
||||
groups fetched rows. Verify existing endpoint returns enough history.
|
||||
|
||||
## Task 4 — Export CSV (#5)
|
||||
**FE only**. `lib/csv.ts` `toCsv(rows, columns)` + `downloadCsv(filename, csv)`.
|
||||
Add "Export CSV" button on `/moderation` (exports current actions) and
|
||||
`/messages` (exports current list). Pure client-side, read-only. No backend.
|
||||
|
||||
## Task 5 — Activity Heatmap (#6)
|
||||
**Backend**: add `messages.activity` oRPC: per-channel message count grouped by
|
||||
hour-of-day (0–23) over last 14 days. Return
|
||||
`{ channels: {channelId, name, byHour: number[24]}[], max }`. Use SQL
|
||||
`EXTRACT(hour from ...)` + group by channel. Channel name from
|
||||
`message.metadata->'channel'->>'channelName'`.
|
||||
**FE**: `useMessageActivity` hook + `<ActivityHeatmap>` SVG grid (channels ×
|
||||
24h, color intensity = count/max). Place on `/messages` or `/dashboard`.
|
||||
|
||||
## Verification checklist
|
||||
- [ ] `pnpm typecheck && pnpm lint && pnpm build` green for gateway, backend, frontend
|
||||
- [ ] Backend `/trpc/moderation/trends` returns categories/severities/actions
|
||||
- [ ] Backend `/trpc/messages/activity` returns byHour grids
|
||||
- [ ] WS `moderation_action` received by FE (log or visible live row)
|
||||
- [ ] No admin/write endpoint added; all public read-only
|
||||
- [ ] No User Reputation code anywhere (grep "reputation|strike|reputasi")
|
||||
- [ ] Deploy via push; all 3 services `running`; moderation + messages pages load
|
||||
|
||||
## Files touched (summary)
|
||||
- gateway: `shared/redis-channels.ts`, `event-broadcaster/eventTypes.ts`,
|
||||
`event-broadcaster/eventBroadcaster.ts`, `message-capture/moderationActionsDb.ts`
|
||||
- backend: `shared/redis-channels.ts`, `orpc/router.ts`,
|
||||
`modules/moderation/moderation.service.ts` (+repository),
|
||||
`modules/messages/messages.service.ts` (+repository, +schema)
|
||||
- frontend: `lib/ws/*`, `hooks/use-moderation.ts`, `hooks/use-messages.ts`,
|
||||
`lib/csv.ts`, `lib/types/*`, `app/(dashboard)/moderation/view.tsx`,
|
||||
`app/(dashboard)/messages/view.tsx`, new components under `components/`
|
||||
|
||||
## Status: COMPLETE (deployed + verified)
|
||||
- Commit 9b3134d: features #2–#6 (live feed, trends, timeline, CSV export, heatmap)
|
||||
- Commit 2a8f6d9: user reputation feature fully removed (643 deletions, no trace in src/tests)
|
||||
- Migration 0016 applied: user_reputations DROPPED (DB verified: false)
|
||||
- All 3 services active (gateway + backend restarted 18:29, frontend running)
|
||||
- Gateway typecheck/lint/test(117 passed); backend typecheck/lint/build; FE lint/build — all GREEN
|
||||
|
||||
## Verification
|
||||
- moderation/stats WS returns data (32 actions) → WS adapter works
|
||||
- DB: user_reputations gone; moderation_actions explainability cols present
|
||||
- Live Feed: gateway publishes discord:moderation:action → backend WS (same path as guild_member_*)
|
||||
- Trends/Activity: backend router procedures registered (typecheck+tsc), same WS adapter
|
||||
@@ -0,0 +1,119 @@
|
||||
# GMW — Fitur Publik Lanjutan #7–#15 + Bug Fix Reputation Removal
|
||||
|
||||
> **For Hermes:** Implement task-by-task. Build + lint + typecheck each service after its
|
||||
> changes. Deploy via push to main (CI handles Nix build + systemd). Apply any new
|
||||
> drizzle migration MANUALLY (systemd does NOT run migrations).
|
||||
> Hard constraint (user): public read-only web, fully automatic, rules in code,
|
||||
> NO admin endpoints, NO shadow mode, NO per-user reputation aggregation.
|
||||
|
||||
## Bug fix discovered during planning (MUST do first)
|
||||
`services/backend/src/modules/dashboard/dashboard.repository.ts` still references
|
||||
`pgUserReputationsTable` (import line 8; JOINs at lines 173 + 457) — that table was
|
||||
DROPPED in migration `0016`. `dashboard.listUsers` / `dashboard.userDetail` will
|
||||
**crash at runtime** (undefined table). Remove the import + the `r.*` join columns
|
||||
(`trust_score`, `clean_message_streak`, `total_infractions`) from both queries.
|
||||
This is a regression introduced by the reputation removal commit.
|
||||
|
||||
## Features to implement (#7–#15)
|
||||
All reuse existing infra: `moderation_actions`, `messages`, `channel_cultures`,
|
||||
`term_glossary_cache`, `ai_analysis_runs`, `message_edits`, gateway cron (for #15),
|
||||
WS (proven Live Feed pattern), oRPC over WS (proven), pure-SVG charts (no libs).
|
||||
|
||||
| # | Feature | Data source | Surface |
|
||||
|---|---------|-------------|---------|
|
||||
| 7 | Flagged Link / Scam Domain Reporter | regex URL from `moderation_actions.content`/`evidence` | `/moderation` |
|
||||
| 8 | Top Flagged Channels | join `moderation_actions.message_id`→`messages.channel_id` | `/moderation` |
|
||||
| 9 | Moderation Heatmap by Hour | `moderation_actions.created_at` hour-of-day | `/moderation` |
|
||||
| 10 | Flag Category Drill-down | `moderation_actions.categories` (reuse Trends) | `/moderation` FE-only |
|
||||
| 11 | Channel Culture Glossary | `channel_cultures` (exists) | new `/channels` panel |
|
||||
| 12 | Term Knowledge Base | `term_glossary_cache` (exists) | new `/glossary` panel |
|
||||
| 13 | Edit/Evasion Tracker | `message_edits` (exists) | `/messages` |
|
||||
| 14 | Auto-mod Coverage Stats | `ai_analysis_runs` (exists) | `/moderation` metric tiles |
|
||||
| 15 | Weekly Digest (auto, cron) | aggregate #7/#8/#9 → Discord via gateway cron | gateway cron + `/moderation` |
|
||||
|
||||
## Architecture per layer
|
||||
|
||||
### Backend (oRPC, `services/backend/src`)
|
||||
- New repository methods (add to existing repos, follow `getTrends` SQL style):
|
||||
- `moderation.repository.ts`:
|
||||
- `getTopFlaggedDomains(days)` — `regexp_matches(content,'https?://([^/\s]+)')` on
|
||||
`moderation_actions WHERE created_at>=since`, group by host, COUNT, order DESC LIMIT 20.
|
||||
- `getTopFlaggedChannels(days)` — join `moderation_actions a` LEFT JOIN `messages m`
|
||||
ON `m.id=a.message_id`, group by `m.channel_id`, COUNT, order DESC LIMIT 15.
|
||||
Channel name via `m.metadata::jsonb->'channel'->>'channelName'`.
|
||||
- `getHourlyModeration(days)` — `EXTRACT(HOUR FROM to_timestamp(created_at/1000))`
|
||||
group by hour, COUNT, severity breakdown. (24 rows)
|
||||
- `getFlaggedByCategory(days, category)` — list actions where `categories` contains
|
||||
`category` (reuse `listActions` filter or new query), for drill-down #10.
|
||||
- `getCoverage(days)` — from `ai_analysis_runs`: total runs, status breakdown
|
||||
(clean/flagged/warn/error/pending), coverage % = (analyzed)/(captured in window).
|
||||
- `dashboard.repository.ts` (or new `knowledge.repository.ts`):
|
||||
- `listChannelCultures(limit, search?)` — `channel_cultures` rows (channel_id,
|
||||
guild_id, channel_name from messages metadata, culture_summary, last_analyzed_at).
|
||||
- `listGlossary(limit, search?)` — `term_glossary_cache` (term, definition, source_url,
|
||||
resolved_at, hit_count) order by hit_count DESC.
|
||||
- `messages.repository.ts`:
|
||||
- `getEditHistory(limit, channelId?)` — `message_edits` join `messages` for
|
||||
old_content + channel + username + edited_at, order DESC LIMIT.
|
||||
- `moderation.service.ts` / `dashboard.service.ts` / `messages.service.ts`: thin wrappers.
|
||||
- `orpc/router.ts`: add procedures (follow `trends` shape):
|
||||
- `moderation.topDomains`, `moderation.topChannels`, `moderation.byHour`,
|
||||
`moderation.byCategory` (input `{days,category}`), `moderation.coverage`.
|
||||
- `dashboard.channelCultures`, `dashboard.glossary`.
|
||||
- `messages.editHistory`.
|
||||
|
||||
### Frontend (`services/frontend/src`)
|
||||
- `lib/types/moderation.ts`: add `FlaggedDomain`, `FlaggedChannel`, `HourlyModeration`,
|
||||
`ModerationCoverage` interfaces.
|
||||
- `lib/types/index.ts` (+ message.ts): add `ChannelCultureRow`, `GlossaryRow`, `EditHistoryRow`.
|
||||
- `lib/api/moderation.ts`: add `topDomains`, `topChannels`, `byHour`, `byCategory`, `coverage`.
|
||||
- `lib/api/dashboard.ts` (or messages.ts): add `channelCultures`, `glossary`, `editHistory`.
|
||||
- `lib/api/server.ts`: add SSR seed fetchers (follow `getModerationStats`).
|
||||
- `hooks/use-moderation.ts`: add `useTopDomains`, `useTopChannels`, `useHourlyModeration`,
|
||||
`useByCategory`, `useCoverage`. `hooks/use-dashboard.ts`/`use-messages.ts`: add culture/glossary/edit hooks. `hooks/index.ts`: export all.
|
||||
- New components (pure SVG/CSS, reuse `GlassPanel`/`SectionHeader`/`Badge`/`Donut`):
|
||||
- `components/ScamDomains.tsx`, `components/TopChannels.tsx`, `components/ModerationHeatmap.tsx`,
|
||||
`components/CoverageTiles.tsx`, `components/ChannelCultureGlossary.tsx`,
|
||||
`components/TermGlossary.tsx`, `components/EditHistory.tsx`.
|
||||
- Wire into `app/(dashboard)/moderation/view.tsx` (grid col-span-2/3/5 as space allows)
|
||||
and `app/(dashboard)/messages/view.tsx` (EditHistory panel) and new route pages
|
||||
`app/(dashboard)/channels/page.tsx` + `app/(dashboard)/glossary/page.tsx` with
|
||||
matching `view.tsx` (follow existing page→view SSR pattern; check `app/(dashboard)/dashboard/page.tsx`).
|
||||
- Export CSV buttons reuse `lib/csv.ts` `downloadCsv` (client-side) for domains/channels/edits.
|
||||
|
||||
### Gateway (#15 Weekly Digest)
|
||||
- Add a cron/interval in `services/discord-gateway` (check existing scheduler pattern —
|
||||
search `setInterval`/`cron` in `src`). On a 7-day cadence, query backend oRPC
|
||||
(`dashboard.activity`, `moderation.trends`, `moderation.topChannels`) — OR compute
|
||||
directly via a shared repository — and post a formatted summary to the monitor guild
|
||||
channel (via existing `discordClient.channels.send` helper). Fully automatic, no UI.
|
||||
|
||||
## Files touched (summary)
|
||||
- backend: `modules/moderation/{repository,service}.ts`, `modules/dashboard/{repository,service}.ts`,
|
||||
`modules/messages/{repository,service}.ts`, `orpc/router.ts`, `shared/index.ts` (if new tables),
|
||||
`lib/types/*` (FE)
|
||||
- frontend: `lib/api/*`, `lib/types/*`, `hooks/*`, `components/*`, `app/(dashboard)/*`
|
||||
- gateway: new digest scheduler + (none if reuse backend) maybe `shared/redis-channels.ts`
|
||||
|
||||
## Constraints / pitfalls (from gmw-ops skill)
|
||||
- `created_at` is bigint epoch-MS — compare with `<`/`>`, do NOT divide by 1000 in SQL.
|
||||
- Pure SVG only — frontend has ZERO chart libs.
|
||||
- `Badge` Tone = signal|amber|vermilion|neutral (no "rose").
|
||||
- Frontend WS import is `@/lib/ws/context`; method `on` not `subscribe`.
|
||||
- Commit author `asepharyana`, no Co-Authored-By.
|
||||
- Rebuild `dist/` after gateway changes; apply drizzle migrations manually.
|
||||
|
||||
## Verification
|
||||
- Per service: `pnpm typecheck && pnpm lint && pnpm build` green.
|
||||
- Gateway: `pnpm test` (117+ pass).
|
||||
- Live: `moderation/stats` WS returns data (proves adapter); new procedures registered
|
||||
(typecheck = proof). `systemctl show` new ActiveEnterTimestamp after deploy.
|
||||
- DB: confirm `channel_cultures`/`term_glossary_cache`/`message_edits`/`ai_analysis_runs`
|
||||
have rows before relying on them (some may be empty → components handle empty state).
|
||||
|
||||
## Execution order
|
||||
1. Bug fix dashboard.repository (reputation JOIN) — deploy-safe.
|
||||
2. Backend repositories + service + router (#7,#8,#9,#14 dashboard; #11,#12; #13).
|
||||
3. FE types + api + hooks + components + wire (#7,#8,#9,#10,#11,#12,#13,#14).
|
||||
4. Gateway #15 digest (if scheduler exists) — verify via log, not UI.
|
||||
5. Build/lint all 3 services; commit; push; monitor CI; apply migrations; verify live.
|
||||
@@ -0,0 +1,111 @@
|
||||
# AI Analysis Flow — Audit & Optimization (discord-gateway)
|
||||
|
||||
**Goal:** Analisis alur AI analysis end-to-end, temukan bug/inconsistency yang merusak kualitas verdict, lalu perbaiki root cause-nya.
|
||||
|
||||
## Scope
|
||||
- `services/discord-gateway/src/modules/ai-moderation/**`
|
||||
- Tidak menyentuh chatbot backend / frontend.
|
||||
|
||||
## Alur saat ini (hasil tracing)
|
||||
```
|
||||
message capture → aiAnalyzer.queueMessageAnalysis(messageId)
|
||||
→ batchScheduler.scheduleConversationAnalysis(conversationKey) [debounce 250ms, CB gate]
|
||||
→ messageStore.getPendingMessagesByConversation(≤200)
|
||||
→ skipAgeRestrictedMessages
|
||||
→ pickBatchWithinBudget(14000 tokens, 50/msg)
|
||||
→ processBatch [Piscina worker, ≤4 threads]
|
||||
→ ai-analysis-worker.processBatch
|
||||
→ getConversationContextBefore(20 msgs) + attachments
|
||||
→ attachment-upload race guard (pending upload → skip)
|
||||
→ runModerationAnalysis
|
||||
→ Phase 1: exact-hash cache (PG text_analysis_cache, per channel/thread)
|
||||
→ Phase 2: semantic cache (embedTexts → Qdrant batch search; PG fallback)
|
||||
→ split text-only vs media
|
||||
→ runTextOnlyBatch: URL fetch + wiki search + glossary (paralel)
|
||||
→ dedup short messages → sub-batches (60/sub-batch)
|
||||
→ vision evidence utk URL images (hoisted, 15s cap per image)
|
||||
→ callModerationLLM per sub-batch (stream:true, retries 3, JSON parse + correction retry)
|
||||
→ runMediaBatch: download → vision per image (cache LRU→DB→live, lock) → 1 LLM call
|
||||
→ setCachedTextModeration (PG + Qdrant upsert w/ embedding)
|
||||
→ normalizeResult (confidence clamp, fallback analysis)
|
||||
→ updateMessagesAIAnalysisBulk → broadcast + scheduleAutoDelete
|
||||
→ recovery worker tiap 10s: pending keys → re-schedule; incomplete → individual fallback queue
|
||||
→ individual fallback: 1 msg = 1 worker job (context + full LLM)
|
||||
→ cache prune tiap 6 jam (PG expired + Qdrant expired points)
|
||||
```
|
||||
|
||||
## Temuan audit (ranked)
|
||||
|
||||
### F1 — Cache hit menghapus status "warn" (BUG AKURASI)
|
||||
`moderationOrchestrator.ts` Phase-2 semantic hit & PG-fallback memetakan status via
|
||||
`parseQdrantVerdict`: storedStatus bukan "warn"/"flagged" → dipaksa "clean".
|
||||
TAPI exact-hash lookup (`getCachedTextModeration`, textCacheStore.ts:288-295) lebih parah:
|
||||
hanya menerima "clean"|"flagged" — **"warn" jatuh ke branch flags.length===0 ? clean : flagged**
|
||||
→ warn dengan flags=["conflict_instigation"] dibaca sebagai FLAGGED.
|
||||
Efek: auto-delete eligibility (butuh recommendedAction delete/escalate + severity list) salah baca;
|
||||
dashboard menampilkan flagged padahal verdict asli warn. Root cause: type narrowing legacy
|
||||
(`status: "clean" | "flagged"`) tidak diupdate ketika "warn" ditambahkan ke schema.
|
||||
|
||||
### F2 — Exact-cache key mengabaikan edit (BUG EVASION)
|
||||
Key = sha256(content)+context. Pesan yang DIEDIT (`edited_content`) menghasilkan hash berbeda,
|
||||
tapi verdict lama utk konten pre-edit tetap hidup; lebih penting: pesan edited="true" adalah sinyal
|
||||
evasion di prompt, sedangkan cache bisa menyajikan verdict dari konten lama jika content sama.
|
||||
(Minor, tapi konsistensi: `resolveIsEdited` ada di prompt, tidak ada di cache key.)
|
||||
|
||||
### F3 — `pickBatchWithinBudget` skip-bukan-break (LATENSI/KUALITAS)
|
||||
Loop `if (usedTokens + msgTokens <= maxTokens) {push}` — pesan BESAR di tengah list dilewati
|
||||
dan iterasi lanjut mencoba msg berikutnya. Efek: batch berisi "lubang" (msg pending tetap pending,
|
||||
dianalisis di gelombang berikutnya = LLM call tambahan). Ini by-design tolerable, tapi ada bug halus:
|
||||
pesan >budget tunggal tidak pernah masuk (scheduler sudah punya fallback slice(0,1), OK).
|
||||
Keputusan: biarkan (bukan bug nyata), catat saja.
|
||||
|
||||
### F4 — `callModerationLLM` max_tokens 16384 hardcoded (COST)
|
||||
Sub-batch 60 pesan × output ~150 token/pesan ≈ 9k token cukup; 16k aman. Biarkan.
|
||||
|
||||
### F5 — Dead code builder user-profile/reputation
|
||||
`buildUserProfilesBlock`, `buildUserProfileRef`, `UserProfileEntry` di moderationBuilders.ts
|
||||
tidak dipakai lagi sejak context minimization (hanya tests). `<user_history>` juga tak pernah
|
||||
di-inject (rules masih menyebutnya — misleading bagi model). Bersihkan referensi prompt.
|
||||
|
||||
### F6 — rules.ts menyebut `<user_history>` yang tidak pernah ada di payload
|
||||
Model diberi instruksi tentang blok yang tak pernah muncul → pemborosan token + potensi
|
||||
kelakuan aneh ("menunggu" data yang tak ada). Hapus/ubah kalimat.
|
||||
|
||||
### F7 — system.ts "Blok Data" menyebut `<term_glossary> (SearXNG)` — STALE
|
||||
Sumber sudah Wikipedia. Komentar kode & teks prompt menyebut SearXNG. Perbaiki teks (kecil).
|
||||
|
||||
### F8 — output.ts typo "secifik", baris tabel `-|-` rusak
|
||||
Kualitas prompt: typo + markdown table broken (`||-`) di beberapa baris. Rapikan.
|
||||
|
||||
### F9 — llmCaller parse-error correction tail hanya di SYSTEM
|
||||
Correction tail ditambahkan ke system prompt; provider caching fine, tapi preview invalid
|
||||
content (800 char) ikut SYSTEM — ok. Skip.
|
||||
|
||||
### F10 — `getLlmSemaphore` race kecil saat config berubah di tengah flight
|
||||
Non-issue praktis (config statis per proses). Skip.
|
||||
|
||||
## Keputusan perbaikan (yang dieksekusi sekarang)
|
||||
1. **F1 (utama):** normalisasi status di SATU tempat — `normalizeStoredStatus()` di
|
||||
textCacheStore.ts yang menerima clean/warn/flagged; pakai di getCachedTextModeration
|
||||
DAN parseQdrantVerdict; perluas return types ke union penuh. Orchestrator tinggal pakai.
|
||||
2. **F6+F7+F8:** bersihkan stale references di prompts (user_history, SearXNG, typo).
|
||||
3. **F5:** hapus dead builders + test-nya (biome/tsc yang jaga).
|
||||
4. Regression test untuk F1 (vitest): warn tersimpan → warn terbaca (exact + qdrant path).
|
||||
|
||||
## Files touched
|
||||
- services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts (F1)
|
||||
- services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts (type only)
|
||||
- services/discord-gateway/src/modules/ai-moderation/prompts/rules.ts (F6)
|
||||
- services/discord-gateway/src/modules/ai-moderation/prompts/system.ts (F7)
|
||||
- services/discord-gateway/src/modules/ai-moderation/prompts/output.ts (F8)
|
||||
- services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts (F5)
|
||||
- services/discord-gateway/tests/contextEnrichment.test.ts (F5 test cleanup + F1 regression test baru)
|
||||
|
||||
## Verification
|
||||
```
|
||||
cd services/discord-gateway
|
||||
npx tsc --noEmit
|
||||
npx biome check --diagnostic-level=error .
|
||||
npx vitest run
|
||||
```
|
||||
Semua harus hijau sebelum commit. Deploy via GHA (push main) — user konfirmasi belakangan.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Optimisasi "non-issue" AI analysis pipeline
|
||||
|
||||
## Scope
|
||||
Dua item yang sebelumnya dinyatakan non-issue, kini dioptimalkan + 1 bug ordering
|
||||
yang ditemukan saat menelusuri:
|
||||
|
||||
1. **pickBatchWithinBudget: skip → break.** Pesan diurutkan `created_at ASC`
|
||||
oleh DB. Setelah budget habis, pesan berikutnya pasti lebih besar/lebih kecil
|
||||
arbitrer — skip-then-take menghasilkan batch non-kontigu (ada gap analisis
|
||||
di tengah timeline). Ubah jadi stop at first overflow (break) supaya prefix
|
||||
kronologis utuh; sisanya otomatis diambil gelombang berikutnya
|
||||
(`shouldScheduleNext` sudah selalu true setelah sukses).
|
||||
2. **max_tokens dinamis.** Hard-coded 16384 di llmCaller.ts → parameter
|
||||
opsional `maxTokens?`; default tetap 16384. Caller text/media batch pass
|
||||
nilai berbasis ukuran prompt (tiktoken) dengan floor/ceiling.
|
||||
3. **Bug ordering UPDATE..RETURNING (bonus).** messagesAnalysis.ts
|
||||
`getPendingMessagesByConversation`: SELECT ids di-order `created_at ASC`
|
||||
tapi UPDATE...RETURNING tanpa ORDER BY → urutan rows balik tidak
|
||||
terjamin. Konsumen pakai messages[0] sebagai anchor konteks
|
||||
(beforeCreatedAt) dan pickBatchWithinBudget asumsi urutan. Fix: re-sort in
|
||||
JS by created_at (stable) sebelum return.
|
||||
|
||||
## Files touched
|
||||
- src/modules/ai-moderation/batchProcessor.ts — break bukan skip; test baru.
|
||||
- src/modules/ai-moderation/llmCaller.ts — param maxTokens.
|
||||
- src/modules/ai-moderation/textBatchProcessor.ts / mediaBatchProcessor.ts —
|
||||
hitung token prompt & pass maxTokens.
|
||||
- src/modules/message-capture/messagesAnalysis.ts — sort hasil RETURNING.
|
||||
- tests/batchBudget.test.ts — baru.
|
||||
|
||||
## Verification
|
||||
cd services/discord-gateway && bun run typecheck && bun run lint && bun run test
|
||||
lalu commit+push, watch GHA, restart service via deploy pipeline.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Spec: Perbagus fitur Voice + Audio Playback (GMW frontend)
|
||||
|
||||
Tanggal: 2026-08-22 · Scope: **frontend only** (backend/gateway API sudah cukup)
|
||||
|
||||
## Masalah (audit)
|
||||
1. Recordings: semua kartu pakai `<audio controls>` native — tampilan identik,
|
||||
tidak ada indikasi which-clip-playing / loading / paused, dan N audio bisa
|
||||
play bareng (overlap).
|
||||
2. Media view: `thumbnailUrl` dari gateway tidak dipakai; tidak ada visual
|
||||
"sedang playing" selain disc spin; queue item semua sama tanpa badge up-next.
|
||||
3. Mini-player (`lib/hooks/use-media-player.tsx`) ada tapi TIDAK PERNAH
|
||||
dimount → dead code, user tidak lihat status musik di halaman lain.
|
||||
4. Voice page: `useMicTransmit.setVolume` + `useVoiceListen.setVolume`
|
||||
tersedia tapi tak ada UI-nya; mic live tidak punya level feedback.
|
||||
|
||||
## Desain
|
||||
|
||||
### A. RecordingAudioPlayer (baru, `components/voice/recording-audio-player.tsx`)
|
||||
Custom player menggantikan `<audio controls>`:
|
||||
- Play/pause button (ikon berubah), spinner saat buffering (`waiting` event).
|
||||
- Progress bar seekable (click-to-seek) + time label `m:ss / m:ss`.
|
||||
- Waveform-ish equalizer bars saat playing (CSS animation, reduced-motion safe).
|
||||
- **Single-playback**: module-level registry `activePlayers` — memainkan satu
|
||||
clip otomatis pause yang lain.
|
||||
- Kartu pemilik player aktif dapat highlight border signal + "Now playing" chip.
|
||||
|
||||
### B. Recordings view — pasang player baru
|
||||
- Ganti `<audio>` → `<RecordingAudioPlayer src download_url>`.
|
||||
- Highlight kartu via state lifted: `playingId` di view, callback `onPlay`.
|
||||
|
||||
### C. Media view polish
|
||||
- Hero: thumbnail (jika `current.thumbnailUrl`) sebagai disc center image;
|
||||
fallback ListMusic icon. Equalizer bars animasi CSS saat `playing`.
|
||||
- Queue row pertama: badge "up next"; baris current track diberi ring signal.
|
||||
- Volume read-only tetap.
|
||||
|
||||
### D. MiniPlayer global
|
||||
- Hapus `lib/hooks/use-media-player.tsx` (dead) — ganti dengan komponen
|
||||
`components/media/mini-player.tsx` yang subscribe `useMediaState` +
|
||||
`useMediaWsSync` langsung (SWR cache shared antar route), mounted di
|
||||
`AppFrame` bawah layar (fixed bottom, hidden di route `/media`).
|
||||
- Menampilkan: thumbnail kecil/judul, tombol skip/stop, link ke /media.
|
||||
|
||||
### E. Voice UI
|
||||
- Mic live: level meter (Equalizer bars) — mic-transmitter sudah punya worklet;
|
||||
tambah `getLevel()` via AnalyserNode pada stream (simple RMS) di hook.
|
||||
- Listen: volume slider (input range) wired ke `listen.setVolume`.
|
||||
- Mic volume slider wired ke `mic.setVolume`.
|
||||
|
||||
## File touched
|
||||
| File | Aksi |
|
||||
|---|---|
|
||||
| services/frontend/src/components/voice/recording-audio-player.tsx | new |
|
||||
| services/frontend/src/app/(dashboard)/recordings/view.tsx | edit |
|
||||
| services/frontend/src/app/(dashboard)/media/view.tsx | edit |
|
||||
| services/frontend/src/components/media/mini-player.tsx | new |
|
||||
| services/frontend/src/components/shell/ambient-app.tsx | mount MiniPlayer |
|
||||
| services/frontend/src/lib/hooks/use-media-player.tsx | delete |
|
||||
| services/frontend/src/hooks/use-voice.ts | tambah micLevel |
|
||||
| services/frontend/src/lib/audio/mic-transmit.ts | expose analyser level |
|
||||
| services/frontend/src/app/(dashboard)/voice/view.tsx | sliders + meter |
|
||||
|
||||
## Verifikasi
|
||||
1. `pnpm lint` (biome) + `pnpm build` clean.
|
||||
2. Smoke di port **4024** (BUKAN 4017) → curl 200 semua route.
|
||||
3. Commit (tanpa trailer) → push → `gh run watch` → live check
|
||||
https://imphnen.asepharyana.my.id/{media,recordings,voice}/ = 200.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Spec: Optimasi AI Analysis GMW — Naikkan Cache Hit Tanpa Kehilangan Akurasi
|
||||
|
||||
Tanggal: 2026-08-24 · Repo: `~/GMW` (branch `main`) · Service: `services/discord-gateway`
|
||||
|
||||
## Latar & Evidence (audit 2026-08-24)
|
||||
|
||||
State produksi:
|
||||
- Qdrant `gmw_text_moderation`: **1.550 poin, status green** (vectors size 2048, Cosine).
|
||||
- PG `text_analysis_cache`: 1.634 row `user_moderation`, 277 `vision_llm`; **sum(hit_count) = 0** →
|
||||
hit-rate tidak pernah terukur.
|
||||
- Embedding aktif (`AI_LLM_EMBEDDING_MODEL` set, Nemotron-embed, dim 2048), `AI_LLM_EMBEDDING_MIN_SIMILARITY`
|
||||
tidak diset di BWS → default **0.97** (sangat konservatif).
|
||||
- Messages: 9.375 total; 643 status `error` (banyak retry), 49 pending.
|
||||
|
||||
Temuan audit alur (`moderationOrchestrator.ts` → `textCacheStore.ts` → `qdrantClient.ts`,
|
||||
`textBatchProcessor.ts`, `urlFetcher.ts`, `wikipediaClient.ts`, `visionAnalyzer.ts`):
|
||||
|
||||
| # | Temuan | Dampak |
|
||||
|---|--------|--------|
|
||||
| F1 | Exact-hash cache key menyertakan context (channel/thread) → teks sama di channel lain selalu miss | Killer hit-rate #1 |
|
||||
| F2 | Semantic tier TIDAK memfilter context (Qdrant payload tak punya context) — sudah global tapi hanya aman krn sim 0.97 ketat | Inkonsisten dgn exact tier |
|
||||
| F3 | Phase-1 lookup loop `await getCachedTextModeration(key)` per pesan → N round-trip PgBouncer per batch (60 msg = 60 query serial) | Latensi + beban DB |
|
||||
| F4 | Verdict actionable (flagged/warn) dan clean sama-sama boleh di-serve semantic; toleransi akurasi beda | Risiko akurasi |
|
||||
| F5 | `hit_count` tidak pernah di-increment oleh reader manapun | Hit-rate tak terukur |
|
||||
| F6 | `wikipediaSearch()` (blok `<web_searches>`) tanpa cache — re-fetch tiap batch utk query sama | Latensi + spam ke WP |
|
||||
| F7 | `fetchUrlSafely()` tanpa cache — link sama di batch berikutnya di-download lagi penuh | Latensi + bandwidth |
|
||||
| F8 | Vision cache key dari data-URL base64 hasil resize → attachment sama via jalur berbeda (URL vs embed) = key beda → re-download + re-vision | Duplikasi kerja vision |
|
||||
|
||||
Non-goals: mengubah pipeline enforcement (auto-mute/ban trust-store writes), mengubah prompt
|
||||
kebijakan moderasi, mengubah model/embedding provider.
|
||||
|
||||
## Desain
|
||||
|
||||
Semua perubahan degrade gracefully — cache gagal → perilaku lama (LLM). Akurasi dilindungi
|
||||
asimetris: **hemat boleh untuk verdict non-actionable, konservatif untuk yang memicu aksi.**
|
||||
|
||||
### D1 — Cache metrics (F5)
|
||||
- `textCacheStore.getCachedTextModeration()`: saat hit valid, increment `hit_count`
|
||||
(`UPDATE ... SET hit_count = hit_count + 1`) fire-and-forget (`.catch(()=>{})`), jangan blokir return.
|
||||
- Log info periodik ringkas di orchestrator sudah ada ("User moderation cache applied") — cukup.
|
||||
|
||||
### D2 — Batched exact-cache lookup (F3)
|
||||
- Fungsi baru `getCachedTextModerations(keys: string[]): Promise<Map<string, StoredModerationVerdict>>`
|
||||
di `textCacheStore.ts`: **satu** `SELECT ... WHERE text = ANY($1)` (chunk 200 key/query),
|
||||
parse + `normalizeStoredStatus` per row (reuse helper existing).
|
||||
- Orchestrator fase-1: kumpulkan semua key unik → satu call batched → distribusi hasil.
|
||||
- Semantik identik dengan loop lama (row expired/error-artifact tetap miss); hanya jumlah round-trip
|
||||
yang turun N→1.
|
||||
|
||||
### D3 — Global exact reuse untuk verdict non-actionable (F1)
|
||||
- Key scoped-context TETAP ditulis (kompatibel, invalidasi moderator tetap presisi).
|
||||
- Reader tambahan: kalau key `<ctx>:<hash>` miss, coba key legacy global `text_mod:<hash>` (bare).
|
||||
- Guard akurasi (WAJIB semua terpenuhi):
|
||||
- `status === "clean"` DAN `flags.length === 0`;
|
||||
- `confidence >= AI_CACHE_GLOBAL_REUSE_MIN_CONFIDENCE` (default 0.85);
|
||||
- `recommendedAction === "none"`;
|
||||
- umur entry ≤ `AI_CACHE_GLOBAL_REUSE_MAX_AGE_H` (default 72h) — cek `analyzed_at`.
|
||||
- Flag baru `policyVersion: "cached-global-clean-2026-08"` supaya terlacak di dashboard/log.
|
||||
- Verdict flagged/warn TETAP context-scoped (tidak pernah lintas channel).
|
||||
|
||||
### D4 — Semantic dua-band similarity (F2+F4)
|
||||
- Config baru: `AI_LLM_EMBEDDING_MIN_SIMILARITY_ACTIONABLE` default **0.97** (perilaku lama),
|
||||
`AI_LLM_EMBEDDING_MIN_SIMILARITY_CLEAN` default **0.92**, keduanya coerce number 0..1.
|
||||
- Satu Qdrant batch search pakai threshold RENDAH (0.92). Per hit, klasifikasi ulang:
|
||||
- verdict non-actionable (clean, no flags, action=none): terima jika `score >= CLEAN_BAND`;
|
||||
- verdict actionable (warn/flagged atau flags ada / action != none): terima hanya jika
|
||||
`score >= ACTIONABLE_BAND` (0.97 — persis gate lama);
|
||||
- di antara dua band → buang hit, pesan lanjut ke LLM (fail-open ke akurasi).
|
||||
- Legacy PG fallback path: filter serupa di `findSimilarTextModeration` via parameter band.
|
||||
|
||||
### D5 — Cache Wikipedia search (F6)
|
||||
- `wikipediaClient.wikipediaSearch(query)`: cek `cacheGet(makeCacheKey("wikisearch", q))` dulu;
|
||||
miss → fetch (timeout existing) → sukses & hasil non-kosong → `cacheSet(..., TTL 6h)`.
|
||||
Hasil kosong TIDAK di-cache (biar retry nanti). Redis down → langsung fetch (no-op cache).
|
||||
|
||||
### D6 — Cache URL text fetch (F7)
|
||||
- `urlFetcher.fetchUrlSafely(url)`: wrapper async memoize in-process LRU (max 500, TTL 30 menit)
|
||||
untuk `type === "text"` saja (image tetap selalu fresh-download karena dipakai sbg bukti vision
|
||||
+ buffer besar; error tidak di-cache).
|
||||
- Import `LRUCache` dari `lru-cache` (sudah dep gateway).
|
||||
|
||||
### D7 — Unified vision cache key (F8)
|
||||
- `makeImageCacheKey(imageUrl)` di `textCacheStore.ts`: sebelum hash, strip query Discord CDN
|
||||
(`?ex=&is=&hm=` signed tokens, `format/width/height/size`) — regex `(\?[^#]*)$` dibuang bila host
|
||||
CDN discord (`cdn.discordapp.com`, `media.discordapp.net`, `images-ext-*.discordapp.net`);
|
||||
URL non-Discord: hash full URL seperti sekarang.
|
||||
- Efek: attachment sama yang lolos lewat jalur embed vs inline vs re-fetch dgn token beda → SATU
|
||||
entry cache → skip download+vision kedua kali. Data-URL base64 tetap di-hash apa adanya.
|
||||
|
||||
## File yang disentuh
|
||||
|
||||
1. `src/shared/config/index.ts` — 3 config baru (D3×2, D4×2 — total 4 nilai, 3 baris zod + deskripsi).
|
||||
2. `src/modules/ai-moderation/textCacheStore.ts` — hit_count inc (D1), batched getter (D2),
|
||||
global-reuse guard helper (D3), image-key normalize (D7).
|
||||
3. `src/modules/ai-moderation/moderationOrchestrator.ts` — pakai batched getter (D2),
|
||||
global bare-key fallback (D3), dua-band semantic accept (D4).
|
||||
4. `src/modules/ai-moderation/qdrantClient.ts` — `searchQdrantBatch` menerima threshold rendah
|
||||
(sudah parametrik — mungkin tanpa perubahan; verifikasi).
|
||||
5. `src/modules/ai-moderation/wikipediaClient.ts` — cache layer (D5).
|
||||
6. `src/modules/ai-moderation/urlFetcher.ts` — LRU text-fetch memoize (D6).
|
||||
|
||||
## Schema/type changes
|
||||
|
||||
- Tidak ada migrasi DB (kolom `hit_count`, `analyzed_at`, `expires_at` sudah ada).
|
||||
- Tidak ada perubahan kontrak WS/oRPC/frontend.
|
||||
- Type baru: none public; internal `StoredModerationVerdict` dipakai ulang.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Unit tests baru (`tests/`):
|
||||
- `cacheBatchLookup.test.ts`: batched getter — hit/miss/expired/error-artifact mapping,
|
||||
chunking >200 keys (mock executeAll), hit_count increment called.
|
||||
- `globalReuseGuard.test.ts`: guard menerima clean+conf≥0.85+action none+umur ≤72h;
|
||||
menolak flagged/warn/conf rendah/action≠none/stale.
|
||||
- `semanticBands.test.ts`: clean @0.93 diterima, flagged @0.93 ditolak, flagged @0.98 diterima.
|
||||
- `imageKeyNormalize.test.ts`: URL Discord dgn/ex token → key sama; non-Discord beda query → beda.
|
||||
2. Gate service: `pnpm typecheck && pnpm exec biome check --diagnostic-level=error . && pnpm exec vitest run`.
|
||||
3. Deploy via GHA (`git push origin main`) → watch `Build & Deploy (Nix)` → verifikasi
|
||||
`systemctl show gmw-discord-gateway -p ActiveEnterTimestamp` baru.
|
||||
4. Runtime probe pasca-deploy: journalctl level 30 normal; beberapa jam kemudian
|
||||
`SELECT sum(hit_count) FROM text_analysis_cache WHERE source='user_moderation'` > 0 membuktikan
|
||||
metrics jalan; log "User moderation cache applied" menunjukkan hits>0 pada traffic ramai.
|
||||
|
||||
## Rollback
|
||||
|
||||
Semua fitur behind config defaults yang mempertahankan perilaku lama pada nilai konservatif;
|
||||
rollback = redeploy commit sebelumnya (tanpa migrasi DB, tanpa state eksternal).
|
||||
@@ -0,0 +1,56 @@
|
||||
# Spec: Perbaiki Delay Attachment 162s→<20s (GMW AI Analysis)
|
||||
|
||||
Tanggal: 2026-08-24 · Repo `~/GMW` · Service discord-gateway
|
||||
|
||||
## Evidence (audit produksi)
|
||||
|
||||
Klaster pesan attachment delay ~330–400 detik. Trace pesan `1541417073245290638` (.gif):
|
||||
19:01:08 dibuat → 19:01:09 batch incomplete → fan-out individual → **guard upload-pending
|
||||
mengembalikan `results:[]`** → diperalakukan sukses (`complete ... (undefined)`) → row
|
||||
tertahan `ai_status='processing'` **tanpa penanggung jawab** → 19:06:12 cleanup mengembalikan
|
||||
ke `pending` (tepat 300s) → baru dianalisis. Plus vision gagal 3× utk GIF besar
|
||||
("Stream ended before producing a non-ping SSE event") → degradasi teks.
|
||||
|
||||
## Root causes
|
||||
|
||||
- **A (fatal)**: `individualFallbackProcessor.processIndividualFallback` memperlakukan
|
||||
`ok:true + results:[]` sebagai sukses. Race-guard upload di `ai-analysis-worker.processIndividual`
|
||||
sengaja balik `results:[]` (desain lama) → pesan yatim `processing` sampai cleanup 300s.
|
||||
- **B**: `llmVision` hanya mencoba `stream:true`; kegagalan SSE truncation pada gambar besar
|
||||
= 3 retry sia-sia (semua jalur sama) → bukti media hilang.
|
||||
- **C**: safety-net cleanup 300s terlalu lambat sbg satu-satunya pemulih `processing`.
|
||||
|
||||
## Fix
|
||||
|
||||
1. **F1 — sinyal eksplisit upload-pending**: `IndividualOkResponse` + field opsional
|
||||
`uploadPending?: boolean`. Worker set `uploadPending:true` saat race guard kena.
|
||||
2. **F2 — processor menangani 3 kondisi** via helper murni baru
|
||||
`classifyIndividualWorkerResult(result): "success" | "upload_pending" | "incomplete" | "error"`
|
||||
(modul baru `fallbackResultClassifier.ts`, zero-dep agar mudah dites):
|
||||
- `upload_pending` → tulis ulang row ke `pending` (pola sama dgn revert apiFailed di
|
||||
batchProcessor) + broadcast + **re-schedule analisis percakapan segera**
|
||||
(dynamic import batchScheduler, pola anti-siklus yg sudah ada) → retry dalam ~250ms
|
||||
begitu upload beres. Bukan error, tidak naikkan CB counter.
|
||||
- `incomplete` (flags analysis_incomplete) → perilaku lama (exhausted path).
|
||||
- `error` / `results kosong tanpa penjelasan` → throw transien (retry oleh recovery),
|
||||
BUKAN sukses palsu. Log "(undefined)" hilang.
|
||||
3. **F3 — vision non-stream fallback**: di `llmVision`, jika error match
|
||||
`/Stream ended before producing a non-ping SSE|stream ended/i` → coba SEKALI lagi dengan
|
||||
`stream:false` (router agregasi penuh; timeout tetap 60s). Konversi hard-fail jadi sukses.
|
||||
4. **F4 — turunkan safety net**: default `revertStuckProcessingMessages` 300000 → 120000 ms.
|
||||
|
||||
## File disentuh
|
||||
|
||||
- `src/modules/ai-moderation/fallbackResultClassifier.ts` (BARU, pure)
|
||||
- `src/modules/ai-moderation/ai-analysis-worker.ts` (tipe + set flag uploadPending)
|
||||
- `src/modules/ai-moderation/individualFallbackProcessor.ts` (konsumsi classifier + reschedule)
|
||||
- `src/modules/ai-moderation/llmClient.ts` (fallback non-stream di llmVision)
|
||||
- `src/modules/message-capture/messagesCleanup.ts` (default 120s)
|
||||
|
||||
## Verifikasi
|
||||
|
||||
- Test baru `tests/fallbackResultClassifier.test.ts` (4 klasifikasi + edge kosong).
|
||||
- Gate: tsc --noEmit, biome error-level, vitest run semua hijau.
|
||||
- Deploy GHA sukses; pasca-deploy: pesan attachment baru p50 < 20s
|
||||
(`SELECT percentile_cont(0.5) ... WHERE metadata attachments>0 AND created_at > deploy`),
|
||||
tidak ada lagi "complete ... (undefined)".
|
||||
@@ -0,0 +1,205 @@
|
||||
# GMW FE Rombak — "Constellation Ops" Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
> Setiap task = edit → lint → build/test → smoke → commit (author `asepharyana`, tanpa Co-Authored-By).
|
||||
|
||||
**Goal:** Rombak total FE GMW dari template dashboard klasik menjadi **Constellation Node Graph** — graf node interaktif (three.js force layout) di mana channel/event/moderasi adalah node bintang dan navigasi adalah terbang antar node — untuk semua 9 route sekaligus.
|
||||
|
||||
**Architecture:** Hapus total shell template (TopBar + NavRail + MobileNav + main scroll container). Ganti dengan satu `ConstellationStage`: canvas full-bleed fixed di belakang seluruh app yang me-render force-directed graph per-route (scene), plus overlay HTML mengambang (tanpa card box standar) untuk konten detail. Router tetap Next App Router; perpindahan route = kamera "fly-to" ke scene baru. Data tetap lewat pola SSR seed (`page.tsx` server fetch → `view.tsx` client + SWR fallbackData) Line truncated
|
||||
|
||||
**Tech Stack:** Next.js 16 App Router · React 19 · TypeScript strict · Tailwind v4 (token oklch existing) · three r180 (+ `@types/three`) · d3-force (+ `@types/d3-force`) untuk simulasi layout deterministik · motion v12 (transisi antar-scene) · biome (0 error **dan** 0 warning) · bun:test untuk modul murni.
|
||||
|
||||
---
|
||||
|
||||
## Keputusan Terkunci (2026-08-24, user MythEclipse)
|
||||
|
||||
| Keputusan | Pilihan user |
|
||||
|---|---|
|
||||
| Cakupan | **Semua 9 route sekali jalan** (dashboard, voice, media, recordings, moderation, messages, analysis, channels, glossary) |
|
||||
| Arah struktural | **Constellation Node Graph** — navigasi = terbang antar node |
|
||||
| Tema | **Pertahankan toggle light/dark** (jangan rebuild sistem tema — harden saja) |
|
||||
|
||||
## Kondisi Saat Ini (fakta repo, 2026-08-24)
|
||||
|
||||
- Repo: `/home/code/GMW/services/frontend` (monorepo GMW, deploy Nix-first via GHA "Build & Deploy (Nix)" ke `imphnen.asepharyana.my.id`; nginx gmw-proxy :4009 → Next standalone :4017).
|
||||
- Shell aktif: `src/components/shell/ambient-app.tsx` → `AppFrame` = NavRail + TopBar + `<main>` scroll + MobileNav + MiniPlayer, dipakai SEMUA route via `(dashboard)/layout.tsx`. AmbientCanvas (233 l) masih ada sebagai background di belakang chrome template.
|
||||
- 9 route di `src/app/(dashboard)/`: dashboard (368 l), voice (311), media (293), moderation (424), messages (611), analysis (196), recordings (201), channels (23), glossary (15).
|
||||
- `next.config.ts`: `output: "standalone"` + `trailingSlash: true` → **nav via `router.push`/Link bisa no-op; pakai `<a href>` polos** (pitfall terverifikasi).
|
||||
- Token desain ada di `globals.css` `@theme`: `--color-canvas/-2`, `--color-ink/-soft/-faint`, `--color-signal(+glow)`, amber, vermilion, hairline; font Bricolage Grotesque/JetBrains Mono; `.light` class override + next-themes sudah jalan — JANGAN dibangun ulang.
|
||||
- Sudah ada & dipertahankan: CommandPalette (⌘K), Chatbot, MiniPlayer, primitives (GlassPanel/GlassCard/Badge/…), shared states+skeletons, `lib/format.ts`, `lib/ai-status.ts`, WS typed events (`message_created/updated/deleted/analyzed/snapshot*`, `voice_state`, dll).
|
||||
- Tooling lokal: `pnpm lint|build|format`, `pnpm exec biome check --write --unsafe .` buat import order; bun 1.3.14 tersedia untuk unit test modul murni.
|
||||
- Smoke test SELALU di port non-prod (mis. 4024), cek `ss -ltnp | grep <port>` dulu; JANGAN pernah bind 4017.
|
||||
|
||||
## Struktur Target
|
||||
|
||||
```
|
||||
src/components/shell/
|
||||
constellation-frame.tsx # pengganti AppFrame: tanpa topbar/rail/bottom-bar
|
||||
constellation-stage.tsx # canvas three.js full-bleed + hit-test + kamera
|
||||
route-scenes.ts # map pathname → scene config (nodes/edges/focus)
|
||||
floating-chrome.tsx # brand mark + status dot + route switcher minim + palette hint
|
||||
(hapus setelah migrasi): topbar.tsx, nav-rail.tsx, mobile-nav.tsx, ambient-app.tsx*
|
||||
src/lib/constellation/
|
||||
graph.ts # tipe GraphNode/GraphEdge + builder dari data API (murni)
|
||||
layout.ts # wrapper d3-force deterministik (seeded) + fallback radial (murni)
|
||||
camera.ts # interpolasi kamera fly-to (murni, testable)
|
||||
*.test.ts # bun:test utk modul murni di atas
|
||||
```
|
||||
|
||||
*ambient-canvas/context dievaluasi: bisa di-refactor jadi bagian dari stage atau dihapus.
|
||||
|
||||
### Scene per route (bahasa visual satu kesatuan)
|
||||
|
||||
| Route | Scene |
|
||||
|---|---|
|
||||
| `/dashboard/` | Guild = bintang pusat; channel = node orbit; event WS = pulse edge; metric cluster floating (tanpa box) |
|
||||
| `/channels/` | Semua channel sebagai konstelasi; klik channel = fly-to + panel detail mengambang |
|
||||
| `/moderation/` | Pesan flagged = node merah (vermilion) mengelilingi hub AI-verdict; LiveModerationFeed jadi ribbon ticker |
|
||||
| `/messages/` | Stream pesan = sabuk orbital; node baru masuk lewat animasi dari tepi |
|
||||
| `/voice/` | Stage node di tengah; speaker aktif = node mengorbit dengan glow intensitas |
|
||||
| `/media/` | Galeri spiral node (thumbnail via div bg-image); klik = MiniPlayer handoff |
|
||||
| `/recordings/` | Variasi spiral media, timeline density sebagai cincin |
|
||||
| `/analysis/` | Hub analitik; chart existing dirender sebagai panel translusen mengambang ter-anchor ke node |
|
||||
| `/glossary/` | Term KB = cincin satelit; search memfilter node real-time |
|
||||
|
||||
### Checklist anti-default (wajib lolos sebelum commit akhir)
|
||||
|
||||
- [ ] Tidak ada top bar / nav rail / side panel / bottom prompt bar
|
||||
- [ ] Tidak ada card grid standar sebagai struktur utama
|
||||
- [ ] Layout beda SPASIAL dari versi lama (bukan ganti warna)
|
||||
- [ ] Verifikasi visual sungguhan: browser_navigate + browser_vision → "bukan template dashboard"
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Phase 0 — Fondasi
|
||||
|
||||
#### Task 1: Dependencies + scaffold direktori
|
||||
**Files:** `package.json`, create `src/lib/constellation/`
|
||||
1. `cd /home/code/GMW/services/frontend && pnpm add three @types/three d3-force @types/d3-force && pnpm add -d @types/bun`
|
||||
2. Buat 4 file kosong bertipe sesuai struktur target (ekspor placeholder bertipe agar tsc lolos).
|
||||
3. Run: `pnpm exec tsc --noEmit` → expected PASS.
|
||||
4. Commit: `chore(frontend): scaffold constellation lib + deps`.
|
||||
|
||||
> Catatan: jika `@types/bun` bentrok dengan tsconfig Next (duplikat globals), fallback: exclude `"**/*.test.ts"` dari `tsconfig.json` dan jalankan test hanya via `bun test` (tanpa typecheck). Putuskan saat eksekusi, dokumentasikan di commit body.
|
||||
|
||||
#### Task 2 (TDD): Modul murni graph + layout + camera
|
||||
**Files:** `src/lib/constellation/{graph,layout,camera}.ts` + `*.test.ts`
|
||||
1. **Tulis test dulu** (bun:test):
|
||||
- `graph.test.ts`: builder `dashboardToGraph()` menghasilkan node guild pusat + N channel + edges benar dari fixture `/api/dashboard/stats|channels` shape (lihat AGENTS.md; nama channel di `message.metadata.channelName`).
|
||||
- `layout.test.ts`: `computeLayout(nodes, edges, {seed:42})` DETERMINISTIK (dua pemanggilan = posisi identik), semua posisi finite, radius dalam bound; `radialLayout()` fallback untuk reduced-motion.
|
||||
- `camera.test.ts`: `flyTo(from,to,t)` easing monotonic, t=0→from, t=1→to.
|
||||
2. Run: `bun test src/lib/constellation` → expected FAIL (belum diimplementasi).
|
||||
3. Implementasi minimal sampai PASS.
|
||||
4. Run: `bun test src/lib/constellation && pnpm exec tsc --noEmit` → PASS.
|
||||
5. Commit: `feat(frontend): constellation graph/layout/camera pure modules (tested)`.
|
||||
|
||||
### Phase 1 — Shell Baru
|
||||
|
||||
#### Task 3: `ConstellationStage` (canvas renderer)
|
||||
**Files:** `src/components/shell/constellation-stage.tsx`
|
||||
- `<canvas>` fixed inset-0 `-z-10`; three.js orthographic 2D-ish render node+edge (sprite/glow shader sederhana — jangan over-engineer; titik + garis + glow cukup).
|
||||
- Interaksi: drag = pan, wheel/pinch = zoom, hover = highlight node + tooltip title, click node ber-`href` = navigasi via `<a>` semantics (window.location assign — hindari router.push no-op).
|
||||
- DPR-aware resize; pause render loop saat `document.hidden`; `prefers-reduced-motion` → render statis tanpa animasi.
|
||||
- Sampling warna dari CSS var (`getComputedStyle`) + `MutationObserver` pada `documentElement.classList` agar ikut toggle dark/light.
|
||||
- Verify: `pnpm lint && pnpm build` PASS → commit `feat(frontend): constellation stage canvas renderer`.
|
||||
|
||||
#### Task 4: `route-scenes.ts` + `ConstellationFrame`
|
||||
**Files:** `src/components/shell/{route-scenes,constellation-frame,floating-chrome}.tsx`
|
||||
- `route-scenes.ts`: `usePathname()` → config scene (builder mana, focus node, overlay slots).
|
||||
- `ConstellationFrame`: render `ConstellationStage` + `children` (overlay HTML absolute, BUKAN flex column bertingkat) + `floating-chrome` (brand mark kiri-atas dgn status dot WS, switcher route minimal kanan-bawah, hint ⌘K). MiniPlayer + Chatbot + CommandPalette tetap termounting.
|
||||
- Switcher & semua nav internal pakai `<a href="/<route>/">` polos (trailingSlash!). Verifikasi klik di browser nanti, bukan cuma baca kode.
|
||||
- Wire `(dashboard)/layout.tsx`: ganti `AppFrame` → `ConstellationFrame` (provider Ambient/WS tetap).
|
||||
- Smoke: `ss -ltnp | grep 4024` kosong → `PORT=4024 pnpm start` → curl semua 9 route = 200 → kill server.
|
||||
- Commit: `feat(frontend): replace classic shell with constellation frame`.
|
||||
|
||||
#### Task 5: Hapus chrome lama + bersih-bersih
|
||||
**Files:** hapus `shell/topbar.tsx`, `shell/nav-rail.tsx`, `shell/mobile-nav.tsx`, update `shell/index.ts`; eval `ambient-*` (refactor jadi layer stage ATAU hapus).
|
||||
- `grep -rn "TopBar\|NavRail\|MobileNav\|AppFrame" src/` → 0 referensi tersisa.
|
||||
- `pnpm exec biome check --write --unsafe .` → import order rapi.
|
||||
- Commit: `refactor(frontend): remove classic dashboard chrome`.
|
||||
|
||||
### Phase 2 — Migrasi 9 Route (batch, tiap batch ship hijau)
|
||||
|
||||
Urutan sengaja dari yang paling graph-native ke paling padat konten:
|
||||
|
||||
#### Task 6: `/dashboard/` + `/channels/`
|
||||
- Dashboard view: guild-star + channel orbit + WS pulse; metric cluster floating (reuse MetricTile tapi tanpa card-box — ubah jadi teks besar + hairline).
|
||||
- Channels: konstelasi channel, klik = fly-to + panel detail (data dari hook `use-channels`/server fetch existing — jangan karang endpoint).
|
||||
- Lint+build+smoke 2 route → commit `feat(frontend): dashboard & channels constellation scenes`.
|
||||
|
||||
#### Task 7: `/glossary/` + `/analysis/`
|
||||
- Glossary: cincin satelit term + search filter node (client-side filter pada node label).
|
||||
- Analysis: hub node + panel chart translusen mengambang (reuse komponen charts/* existing, anchor posisi ke node).
|
||||
- Commit: `feat(frontend): glossary & analysis scenes`.
|
||||
|
||||
#### Task 8: `/messages/` + `/moderation/`
|
||||
- Messages: sabuk orbital stream (reuse sortMessages/logika WS `use-messages.ts` — jangan duplikasi); pesan baru animate-in dari tepi.
|
||||
- Moderation: flagged nodes vermilion + hub verdict; LiveModerationFeed jadi ticker ribbon bawah (bukan panel).
|
||||
- Commit: `feat(frontend): messages & moderation scenes`.
|
||||
|
||||
#### Task 9: `/voice/` + `/media/` + `/recordings/`
|
||||
- Voice: speaker aktif orbit dgn glow (seed dari snapshot `activeSpeakers` server-authoritative — pertahankan perilaku useSpeakers).
|
||||
- Media/Recordings: spiral galeri; thumbnail pakai div background-image (hindari `noImgElement`; kalau terpaksa `<img>` → `// biome-ignore lint/performance/noImgElement: <alasan>`); MiniPlayer handoff tidak boleh regress.
|
||||
- Commit: `feat(frontend): voice, media & recordings scenes`.
|
||||
|
||||
### Phase 3 — Polish, A11y, Tema
|
||||
|
||||
#### Task 10: Motion & micro-interaction
|
||||
- Fly-to antar scene pakai `motion` v12 (interpolasi kamera dari `lib/constellation/camera.ts`).
|
||||
- Overlay: stagger masuk (pattern `animate-stagger` + `staggerDelay(i)` existing); tambah semua `animate-*` baru ke kill-list `prefers-reduced-motion` di `globals.css`.
|
||||
- Commit: `feat(frontend): scene transitions + motion polish`.
|
||||
|
||||
#### Task 11: A11y & fallback semantik
|
||||
- Setiap scene punya fallback semantik: `<nav aria-label="Routes">` visually-hidden berisi link semua route + `<ul>` node (label + href) yang ter-sync dgn graph state.
|
||||
- Keyboard: Tab ke daftar route; panah memindahkan fokus antar node; Enter aktivasi; `focus-visible` ring pakai `--color-ring`.
|
||||
- Kontras ink-on-canvas dicek (WCAG AA utk teks overlay).
|
||||
- Hooks-after-early-return pitfall: compute flags dulu, guard di dalam useEffect.
|
||||
- Commit: `feat(frontend): a11y semantic fallback for constellation`.
|
||||
|
||||
#### Task 12: Hardening tema light/dark
|
||||
- Pastikan canvas sampling var `.light` benar saat toggle (MutationObserver dari Task 3); harden `.light`: `color-scheme: light`, scrollbar/selection, glow opacity turun.
|
||||
- JANGAN menyentuh sistem next-themes.
|
||||
- Commit: `fix(frontend): light theme parity for constellation stage`.
|
||||
|
||||
### Phase 4 — Gerbang Ship (non-negotiable)
|
||||
|
||||
#### Task 13: Verifikasi penuh + deploy
|
||||
1. `pnpm lint` → **Found 0 errors. Found 0 warnings** (warning pun harus bersih).
|
||||
2. `bun test src/lib/constellation` → all PASS; `pnpm exec tsc --noEmit` PASS; `pnpm build` sukses.
|
||||
3. Smoke: port 4024 bersih → start → curl 9 route (dgn trailing slash) semua **200** → kill server.
|
||||
4. `git status` audit: hanya file scope yang ter-commit (`git checkout --` file liar hasil auto-format).
|
||||
5. Push `origin/main` → `gh run watch` "Build & Deploy (Nix)" → hijau.
|
||||
6. Live: curl `https://imphnen.asepharyana.my.id/<route>/` × 9 → 200.
|
||||
7. **Verifikasi visual sungguhan**: browser_navigate ke live URL → browser_vision: "Apakah ini masih template dashboard?" — jawaban HARUS bukan; screenshot dikirim ke user (`MEDIA:<path>`).
|
||||
8. Checklist anti-default dicentang satu per satu di PR/commit message akhir.
|
||||
|
||||
---
|
||||
|
||||
## Files Likely to Change
|
||||
|
||||
```
|
||||
package.json, pnpm-lock.yaml
|
||||
next.config.ts # hanya jika perlu (tidak diharapkan)
|
||||
src/app/(dashboard)/layout.tsx # wire frame baru
|
||||
src/app/(dashboard)/{9 route}/page.tsx|view.tsx # scene migration
|
||||
src/components/shell/** # frame baru + hapus lama
|
||||
src/lib/constellation/** # baru (murni + test)
|
||||
src/app/globals.css # tokens tambahan + reduced-motion kill list
|
||||
biome.json # hanya jika rule intentional baru perlu "off"
|
||||
```
|
||||
|
||||
## Risks / Tradeoffs
|
||||
|
||||
| Risiko | Mitigasi |
|
||||
|---|---|
|
||||
| Perf GPU di device lemah (full-page WebGL) | Node count kecil (<200/route), pause saat tab hidden, fallback radial statis utk reduced-motion; jika tetap berat → downgrade ke Canvas2D renderer dgn API sama |
|
||||
| router.push no-op (standalone+trailingSlash) | Semua nav pakai `<a href>`; verifikasi klik nyata di browser |
|
||||
| Konten padat (messages 611 l) tak muat di metafora graf | Prinsip: graf = struktur/navigasi; DETAIL tetap lewat overlay panel mengambang — konten tidak dikorbankan, hanya wadahnya yang berubah |
|
||||
| Bun test + Next tsconfig konflik types | Exclude `*.test.ts` dari tsconfig (keputusan Task 1) |
|
||||
| Regress fitur existing (theme toggle, palette, chatbot, mini-player, WS sync) | Dipertahankan apa adanya; smoke + klik verifikasi per phase; sortMessages & snapshot voice TIDAK disentuh logikanya |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Tidak ada blocker. Satu preferensi opsional: apakah brand mark perlu jam/uptime seperti TopBar lama — default: tidak (minimal). Keputusan bisa diambil saat review Task 4.
|
||||
@@ -0,0 +1,39 @@
|
||||
# GMW FE — Monokrom Hitam-Putih + Sidebar Ala Menu Game + Ringan di Mobile
|
||||
|
||||
Tanggal: 2026-08-24 · Basis: `eda5c75` (shell usable hasil revert)
|
||||
|
||||
## Tujuan
|
||||
1. Tema **monokrom murni** (hitam-putih, tanpa warna) di dark & light.
|
||||
2. Sidebar (desktop NavRail + mobile dock) beranimasi **ala menu game** — corner
|
||||
brackets, sweep, stagger masuk, marker segitiga.
|
||||
3. **Ringan di mobile**: matikan WebGL ambient di layar kecil, kurangi biaya
|
||||
blur/backdrop, animasi transform/opacity saja.
|
||||
|
||||
## Non-goals
|
||||
- Tidak menyentuh backend, endpoint, hooks/data-flow, struktur route.
|
||||
- Tidak menambah dependensi baru (CSS murni untuk semua animasi).
|
||||
|
||||
## File yang disentuh
|
||||
| File | Perubahan |
|
||||
|---|---|
|
||||
| `src/app/globals.css` | Token mono (dark+light): signal/amber/vermilion → skala putih-abu; `.glass` blur adaptif; kelas baru `.game-nav-item` (bracket ::before/::after, sweep, stagger via `--i`), `.game-frame` (panel sudut terpotong + garis tergambar), keyframes `sweep-x`, `draw-line`, `nav-in`; media query `<md`: blur 18→8px, hambat animasi berat |
|
||||
| `src/components/shell/nav-rail.tsx` | Item pakai `.game-nav-item` + `style={{'--i': n}}`; marker aktif jadi segitiga ▸ putih; hapus box-shadow glow besar (ganti sweep) |
|
||||
| `src/components/shell/mobile-nav.tsx` | Dock mono: tab aktif = bar atas putih + sweep sekali; target sentuh ≥44px; hapus glow blob |
|
||||
| `src/components/shell/topbar.tsx` | Aksen mono + `.game-frame` pada container (cek markup dulu) |
|
||||
| `src/components/ambient/ambient-canvas.tsx` | Early-return WebGL bila `(pointer: coarse)` / lebar <768 / `saveData` / core ≤4; fallback statik CSS tetap |
|
||||
| `src/components/ambient/status/signal tone` (`SIGNAL_RGB`) | Semua tone jadi grayscale (putih; intensitas beda per tone) |
|
||||
| `src/app/(dashboard)/dashboard/view.tsx` | Hero + kartu metrik pakai `.game-frame`/cut-corner sebagai showcase |
|
||||
|
||||
## Keputusan desain
|
||||
- **Full monokrom termasuk danger**: flag/moderation tidak lagi merah —
|
||||
ditandai badge putih-di-atlas-hitam inversi + pulse. Kalau user kangen merah,
|
||||
tinggal isi ulang `--color-vermilion`.
|
||||
- Semua animasi hanya `transform`/`opacity` (compositor-friendly), hormati
|
||||
`prefers-reduced-motion` (sudah ada kill-switch global).
|
||||
|
||||
## Verifikasi (gerbang)
|
||||
1. `tsc --noEmit` bersih; biome 0 error 0 warning.
|
||||
2. `pnpm build` sukses; smoke lokal 4024 → 9 route 200.
|
||||
3. Push → GHA "Build & Deploy (Nix)" hijau → live 9×200.
|
||||
4. Visual check live: desktop (rail game-menu terlihat) + cek rule mobile
|
||||
(media query & gate kode) — screenshot disimpan.
|
||||
@@ -101,31 +101,8 @@
|
||||
buildPhase = pnpmInstall + ''
|
||||
echo "=== Compiling TypeScript ==="
|
||||
npx tsc 2>&1
|
||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
let count = 0;
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const e of fs.readdirSync(dir, {withFileTypes: true})) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (e.name.endsWith('.js')) {
|
||||
const c = fs.readFileSync(p, 'utf8');
|
||||
const pat = /from\s+['\"]@\/([^'\"]+)['\"]/g;
|
||||
const n = c.replace(pat, (m, p1) => {
|
||||
const target = path.join('dist', p1) + '.js';
|
||||
const rel = path.relative(path.dirname(p), target);
|
||||
return 'from \"' + (rel.startsWith('.') ? rel : './' + rel) + '\"';
|
||||
});
|
||||
if (n !== c) { fs.writeFileSync(p, n); count++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
walk('dist');
|
||||
console.log('Fixed ' + count + ' files');
|
||||
"
|
||||
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||
node scripts/fix-imports.mjs
|
||||
echo "=== Build complete ==="
|
||||
'' + pruneProd;
|
||||
|
||||
@@ -202,31 +179,8 @@ WRAPPER
|
||||
pnpm rebuild @discordjs/opus 2>&1 || true
|
||||
echo "=== Compiling TypeScript ===="
|
||||
npx tsc 2>&1
|
||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
let count = 0;
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const e of fs.readdirSync(dir, {withFileTypes: true})) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (e.name.endsWith('.js')) {
|
||||
const c = fs.readFileSync(p, 'utf8');
|
||||
const pat = /from\s+['\"]@\/([^'\"]+)['\"]/g;
|
||||
const n = c.replace(pat, (m, p1) => {
|
||||
const target = path.join('dist', p1) + '.js';
|
||||
const rel = path.relative(path.dirname(p), target);
|
||||
return 'from \"' + (rel.startsWith('.') ? rel : './' + rel) + '\"';
|
||||
});
|
||||
if (n !== c) { fs.writeFileSync(p, n); count++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
walk('dist');
|
||||
console.log('Fixed ' + count + ' files');
|
||||
"
|
||||
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||
node scripts/fix-imports.mjs
|
||||
echo "=== Build complete ==="
|
||||
'' + pruneProd;
|
||||
|
||||
|
||||
@@ -61,6 +61,25 @@ http {
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# ── Backend oRPC (structured data RPCs over WebSocket + HTTP POST)
|
||||
# Browser reaches this via partysocket (wss://…/trpc); SSR/RSC uses
|
||||
# the fetch RPCLink (POST /trpc). Same path, same backend handler:
|
||||
# oRPC's RPCHandler (HTTP) + ORPCWebSocketServer (WS) on :4001. ──
|
||||
location ^~ /trpc {
|
||||
proxy_pass http://gmw_backend$uri$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
# Upgrade headers required for the WebSocket transport; harmless for POST.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# ── Next.js build assets — immutable, edge/shareable ───────────
|
||||
location ^~ /_next/static/ {
|
||||
proxy_pass http://gmw_next$uri$is_args$args;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Migration: Drop materi_documents table (feature removed)
|
||||
-- Run: PGPASSWORD=<pw> psql -h <host> -U <user> -d <db> -f scripts/drop-materi-documents.sql
|
||||
-- Reverses scripts/add-materi-documents.sql which was deleted with the feature.
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS idx_materi_search;
|
||||
DROP INDEX IF EXISTS idx_materi_guild;
|
||||
DROP INDEX IF EXISTS idx_materi_owner;
|
||||
DROP INDEX IF EXISTS idx_materi_category;
|
||||
|
||||
DROP TABLE IF EXISTS public.materi_documents;
|
||||
|
||||
COMMIT;
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@trpc/server": "^11.18.0",
|
||||
"@orpc/server": "1.15.0",
|
||||
"axios": "^1.16.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
|
||||
Generated
+176
-13
@@ -11,9 +11,9 @@ importers:
|
||||
'@discordjs/voice':
|
||||
specifier: ^0.19.2
|
||||
version: 0.19.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
|
||||
'@trpc/server':
|
||||
specifier: ^11.18.0
|
||||
version: 11.18.0(typescript@5.9.3)
|
||||
'@orpc/server':
|
||||
specifier: 1.15.0
|
||||
version: 1.15.0(@opentelemetry/api@1.9.1)(ws@8.21.1)
|
||||
axios:
|
||||
specifier: ^1.16.1
|
||||
version: 1.19.0(debug@4.4.3)
|
||||
@@ -318,6 +318,57 @@ packages:
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@orpc/client@1.15.0':
|
||||
resolution: {integrity: sha512-Qt0FdPSGySdQwy83iUWOq+Iqhw2gM9qHtyxfBbcw1mwOz1s4O2BwUFA3ymVTLIRRNYRgPrQLaBZhp8CacqeePg==}
|
||||
|
||||
'@orpc/contract@1.15.0':
|
||||
resolution: {integrity: sha512-zo8+x+5iqnIMrFdcj+ibVgn6dJneWzLNcD3kZQxrJFGFNAFoxvZbwryR5wOdcHkaSCeS3N7Ib2NydU6pihSYmg==}
|
||||
|
||||
'@orpc/interop@1.15.0':
|
||||
resolution: {integrity: sha512-Ah48o/rc00rN1yP5HcaoVsDCjdUhoh3JP1VibBbvwijhGvdOwRwiw5RWiGu442GSKcrPt9etAGhWXH88q8C3ww==}
|
||||
|
||||
'@orpc/server@1.15.0':
|
||||
resolution: {integrity: sha512-l9FnL1MjQ8g77mB10/8D61okuKHLJBWhuwmd6OF65POHzBF177kMCcbHvRFEqUuxSbzP2eNiZGf3kXPMHmIKiQ==}
|
||||
peerDependencies:
|
||||
crossws: '>=0.3.4'
|
||||
ws: '>=8.18.1'
|
||||
peerDependenciesMeta:
|
||||
crossws:
|
||||
optional: true
|
||||
ws:
|
||||
optional: true
|
||||
|
||||
'@orpc/shared@1.15.0':
|
||||
resolution: {integrity: sha512-A3/JE7pQYSrrRm6/WYJxV3GBhpMdJRPM3h47slQtWBUZe9Sao5En5WBc4tISGQNP2emcez6qIvDziSgD2T+img==}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.9.0'
|
||||
peerDependenciesMeta:
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
|
||||
'@orpc/standard-server-aws-lambda@1.15.0':
|
||||
resolution: {integrity: sha512-laFW7C4t1gEV0NIt4EjCVCBU3kFPnj2FObJLQkTH+DkEOzxVy6zyJ1coIncDSeOPxzbf7rG8cs0B2vQQyuROag==}
|
||||
|
||||
'@orpc/standard-server-fastify@1.15.0':
|
||||
resolution: {integrity: sha512-fcOl2004o3RnZxW7rhLuDCrCKSi/H2eWnyljeeoY+xykcygRf0ONCs2K2bSdxPm6cmvQGAXa9m8VBglG9oERVQ==}
|
||||
peerDependencies:
|
||||
fastify: '>=5.6.1'
|
||||
peerDependenciesMeta:
|
||||
fastify:
|
||||
optional: true
|
||||
|
||||
'@orpc/standard-server-fetch@1.15.0':
|
||||
resolution: {integrity: sha512-XYVfgmIt71YrPJSI7RKRiNWjcyktPYHpevkjeohGNBP5aSMAUL95quZArAWr+XOPp4U56XRao54I6wFMZa4How==}
|
||||
|
||||
'@orpc/standard-server-node@1.15.0':
|
||||
resolution: {integrity: sha512-3ye9SIIhYfJckQwyyGQQRHAEF5vW6QDbax7oi1mOQ9UBHaRDH5kcL28mT0JnETrUvgBoDRx+rfCsQaAOB4n3zw==}
|
||||
|
||||
'@orpc/standard-server-peer@1.15.0':
|
||||
resolution: {integrity: sha512-fsTN+FrdPkseVx99yRenGJYSp0xOrEk9fODk8oh3zzricNYzlZ8GGgCYtDNgt9vFIR/J05dc+Nk76KhcPBQpLw==}
|
||||
|
||||
'@orpc/standard-server@1.15.0':
|
||||
resolution: {integrity: sha512-bje/xn6thDqJY/JQ7xoOjmD1KWE4FsyZOPgnPxwqTZLx/r00stRhLuFvk1hDEGj9UlG7NLZEijDVx0wqvcyDzA==}
|
||||
|
||||
'@oxc-project/types@0.142.0':
|
||||
resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==}
|
||||
|
||||
@@ -515,12 +566,6 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@trpc/server@11.18.0':
|
||||
resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.7.2'
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
||||
|
||||
@@ -674,6 +719,10 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -1083,6 +1132,9 @@ packages:
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1203,6 +1255,10 @@ packages:
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
radash@12.1.1:
|
||||
resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
range-parser@1.3.0:
|
||||
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -1293,6 +1349,10 @@ packages:
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
tagged-tag@1.0.0:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tdigest@0.1.2:
|
||||
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
|
||||
|
||||
@@ -1326,6 +1386,10 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
type-fest@5.8.0:
|
||||
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
type-is@2.1.0:
|
||||
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -1619,6 +1683,97 @@ snapshots:
|
||||
|
||||
'@opentelemetry/api@1.9.1': {}
|
||||
|
||||
'@orpc/client@1.15.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-fetch': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-peer': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
|
||||
'@orpc/contract@1.15.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@orpc/client': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@standard-schema/spec': 1.1.0
|
||||
openapi-types: 12.1.3
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
|
||||
'@orpc/interop@1.15.0': {}
|
||||
|
||||
'@orpc/server@1.15.0(@opentelemetry/api@1.9.1)(ws@8.21.1)':
|
||||
dependencies:
|
||||
'@orpc/client': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/contract': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/interop': 1.15.0
|
||||
'@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-aws-lambda': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-fastify': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-fetch': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-node': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-peer': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
cookie: 1.1.1
|
||||
optionalDependencies:
|
||||
ws: 8.21.1
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
- fastify
|
||||
|
||||
'@orpc/shared@1.15.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
radash: 12.1.1
|
||||
type-fest: 5.8.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@orpc/standard-server-aws-lambda@1.15.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-fetch': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-node': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
|
||||
'@orpc/standard-server-fastify@1.15.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-node': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
|
||||
'@orpc/standard-server-fetch@1.15.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
|
||||
'@orpc/standard-server-node@1.15.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server-fetch': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
|
||||
'@orpc/standard-server-peer@1.15.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
'@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
|
||||
'@orpc/standard-server@1.15.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1)
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
|
||||
'@oxc-project/types@0.142.0': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
@@ -1743,10 +1898,6 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@trpc/server@11.18.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -1928,6 +2079,8 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -2245,6 +2398,8 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-to-regexp@8.4.2: {}
|
||||
@@ -2349,6 +2504,8 @@ snapshots:
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
radash@12.1.1: {}
|
||||
|
||||
range-parser@1.3.0: {}
|
||||
|
||||
raw-body@3.0.2:
|
||||
@@ -2474,6 +2631,8 @@ snapshots:
|
||||
|
||||
std-env@4.2.0: {}
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tdigest@0.1.2:
|
||||
dependencies:
|
||||
bintrees: 1.0.2
|
||||
@@ -2503,6 +2662,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
type-fest@5.8.0:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
|
||||
type-is@2.1.0:
|
||||
dependencies:
|
||||
content-type: 2.0.0
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Rewrite import specifiers in the compiled dist/ so the output runs under
|
||||
// plain `node dist/index.js` (native ESM, no bundler / no tsx).
|
||||
//
|
||||
// Background: tsconfig uses moduleResolution:"bundler", so `tsc` emits BARE
|
||||
// relative specifiers WITHOUT extensions (e.g. `import "./router"`) and leaves
|
||||
// the `@/*` path-alias imports untouched. Node's native ESM resolver rejects
|
||||
// extensionless relative specifiers and knows nothing about the `@/` alias, so
|
||||
// the emitted dist/ crashes at startup (`ERR_MODULE_NOT_FOUND`). This script
|
||||
// fixes both:
|
||||
// 1. `@/foo` -> relative path to dist/foo.js
|
||||
// 2. `./foo` / `../foo` -> `./foo.js` / `../foo.js` (append .js)
|
||||
// Already-extensioned relative imports (.js/.json/.node/.mjs/.cjs) and bare
|
||||
// package specifiers are left untouched (idempotent).
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { join, relative, dirname } from "node:path";
|
||||
|
||||
let count = 0;
|
||||
function walk(dir) {
|
||||
if (!existsSync(dir)) return;
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (e.name.endsWith(".js")) {
|
||||
const c = readFileSync(p, "utf8");
|
||||
const pat = /from\s+['"]([^'"]+)['"]/g;
|
||||
const n = c.replace(pat, (m, spec) => {
|
||||
if (spec.startsWith("@/")) {
|
||||
// Source may already carry an extension (e.g. "@/shared/config/index.js");
|
||||
// only append ".js" when the specifier has none — otherwise we'd
|
||||
// produce "index.js.js".
|
||||
const core = spec.slice(2);
|
||||
let target;
|
||||
if (/\.(js|json|node|mjs|cjs)$/.test(core)) {
|
||||
target = join("dist", core);
|
||||
} else {
|
||||
target = join("dist", core) + ".js";
|
||||
}
|
||||
let rel = relative(dirname(p), target);
|
||||
if (!rel.startsWith(".")) rel = "./" + rel;
|
||||
return `from "${rel}"`;
|
||||
}
|
||||
if (
|
||||
(spec.startsWith("./") || spec.startsWith("../")) &&
|
||||
!/\.(js|json|node|mjs|cjs)$/.test(spec)
|
||||
) {
|
||||
return `from "${spec}.js"`;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
if (n !== c) {
|
||||
writeFileSync(p, n);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("dist");
|
||||
console.log(`Fixed ${count} import specifiers in dist/`);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { nodeHTTPRequestHandler } from "@trpc/server/adapters/node-http";
|
||||
import { onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/node";
|
||||
import express, {
|
||||
type Express,
|
||||
type NextFunction,
|
||||
@@ -8,14 +9,14 @@ import express, {
|
||||
import helmet from "helmet";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { createHealthRouter } from "../modules/health/index.js";
|
||||
import { appRouter } from "../orpc/router";
|
||||
import { errorHandler } from "../shared/middlewares/index.js";
|
||||
import { appRouter } from "../trpc/routers";
|
||||
|
||||
// Auth removed — dashboard is public.
|
||||
// All data APIs (dashboard, messages, moderation, media, voice, recordings,
|
||||
// analysis, chatbot, config, ui-state) now flow over tRPC, served on TWO
|
||||
// analysis, chatbot, config, ui-state) now flow over oRPC, served on TWO
|
||||
// transports sharing the /trpc path:
|
||||
// - WebSocket (browser live RPCs) — see trpc/ws.ts
|
||||
// - WebSocket (browser live RPCs) — see orpc/ws.ts
|
||||
// - HTTP POST (server-side / RSC fetch) — handled below
|
||||
// Only infra endpoints (health, prometheus metrics) remain plain HTTP.
|
||||
|
||||
@@ -31,7 +32,7 @@ export function createHttpApp(): Express {
|
||||
}),
|
||||
);
|
||||
|
||||
// Body parsing (still needed for any JSON POST; tRPC is WS-based)
|
||||
// Body parsing (still needed for any JSON POST; oRPC is WS/HTTP-based)
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
@@ -60,29 +61,27 @@ export function createHttpApp(): Express {
|
||||
// Infra-only HTTP endpoints
|
||||
app.use("/api", createHealthRouter());
|
||||
|
||||
// tRPC over HTTP (server-side / RSC fetch). The context has no WebSocket
|
||||
// here (that's the WS transport's job); procedures don't read ctx.conn, so
|
||||
// a null conn is safe.
|
||||
// NOTE: Express 5 (path-to-regexp v8) rejects the `"/trpc/*"` wildcard route,
|
||||
// and `nodeHTTPRequestHandler` uses `opts.path` as the literal procedure
|
||||
// path (it does NOT derive it from `req.url`). So we mount a plain
|
||||
// middleware and compute the procedure path from the URL ourselves.
|
||||
// oRPC over HTTP (server-side / RSC fetch). The same appRouter the browser
|
||||
// reaches over the /trpc WebSocket. oRPC's node RPCHandler writes the full
|
||||
// response itself; if no procedure matched we fall through to the 404 below.
|
||||
const orpcHandler = new RPCHandler(appRouter, {
|
||||
interceptors: [onError((error) => logger.error({ error }, "oRPC error"))],
|
||||
});
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.path.startsWith("/trpc")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const procPath = req.url.replace(/^\/trpc\/?/, "").split("?")[0] || "/";
|
||||
nodeHTTPRequestHandler({
|
||||
router: appRouter,
|
||||
createContext: () => ({ conn: null }),
|
||||
req,
|
||||
res,
|
||||
path: procPath,
|
||||
}).catch((err: unknown) => {
|
||||
logger.error({ err }, "tRPC HTTP handler failed");
|
||||
if (!res.headersSent) res.status(500).json({ error: "INTERNAL" });
|
||||
});
|
||||
orpcHandler
|
||||
.handle(req, res, { prefix: "/trpc", context: {} })
|
||||
.then(({ matched }) => {
|
||||
if (!matched) next();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.error({ err }, "oRPC HTTP handler failed");
|
||||
if (!res.headersSent) res.status(500).json({ error: "INTERNAL" });
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { createORPCWebSocketServer } from "../orpc/ws.js";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { initializeDatabase } from "../shared/database/index.js";
|
||||
import { createTRPCWebSocketServer } from "../trpc/ws.js";
|
||||
import { startRedisBridge } from "../ws/redis-bridge.js";
|
||||
import { createWebSocketServer } from "../ws/server.js";
|
||||
import { createHttpApp } from "./app.js";
|
||||
@@ -19,7 +19,7 @@ export async function startHttpServer(): Promise<Server> {
|
||||
|
||||
// Attach WebSocket servers to the same HTTP server
|
||||
createWebSocketServer(server); // /ws — voice PCM + gateway events
|
||||
createTRPCWebSocketServer(server); // /trpc — structured data RPCs
|
||||
createORPCWebSocketServer(server); // /trpc — structured data RPCs
|
||||
|
||||
// Start Redis pub/sub bridge to forward discord-gateway events to WS clients
|
||||
await startRedisBridge();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { pgChatbotMessagesTable, pgMessagesTable } from "../../shared/index.js";
|
||||
import { pgChatbotMessagesTable } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
|
||||
const logger = createChildLogger("chatbot.repository");
|
||||
|
||||
@@ -158,7 +158,13 @@ Gaya ngobrol:
|
||||
tool_choice: "auto",
|
||||
max_tokens: 600,
|
||||
temperature: 0.4,
|
||||
stream: true,
|
||||
// Non-streaming: request a single complete response. 9router may
|
||||
// still emit SSE even with stream:false, so the parser below
|
||||
// handle both raw-JSON and SSE bodies.
|
||||
stream: false,
|
||||
// Disable extended thinking / reasoning tokens so the bot answers
|
||||
// directly (ignored by non-reasoning models).
|
||||
reasoning_effort: "none",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
@@ -166,14 +172,16 @@ Gaya ngobrol:
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 45_000,
|
||||
// 9router returns SSE even without stream:true; force stream:true
|
||||
// in the body and read the raw SSE text.
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
|
||||
// Parse SSE `data:` lines → content + tool_calls.
|
||||
const { content, toolCalls } = this.parseSse(response.data as string);
|
||||
// Parse the body into content + tool_calls. 9router may return either
|
||||
// a single JSON object (stream:false honored) or SSE text (stream
|
||||
// implied) — parseResponse handles both.
|
||||
const { content, toolCalls } = this.parseResponse(
|
||||
response.data as string,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
@@ -242,6 +250,60 @@ Gaya ngobrol:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an LLM HTTP body into content + tool_calls. Handles both shapes
|
||||
* 9router can return: a single JSON object (stream:false honored) or SSE
|
||||
* text (stream implied). For SSE we delegate to parseSse.
|
||||
*/
|
||||
private parseResponse(body: string): {
|
||||
content: string;
|
||||
toolCalls: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
} {
|
||||
const trimmed = body.trim();
|
||||
// Non-streaming response: a single JSON object.
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const json = JSON.parse(trimmed) as {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string | null;
|
||||
tool_calls?: Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
function?: { name?: string; arguments?: string };
|
||||
}>;
|
||||
};
|
||||
delta?: unknown;
|
||||
}>;
|
||||
};
|
||||
const msg = json.choices?.[0]?.message;
|
||||
// If the router returned SSE-style shape under `choices[].delta`
|
||||
// (rare), fall through to the SSE parser.
|
||||
if (msg) {
|
||||
const content = msg.content ?? "";
|
||||
const toolCalls = (msg.tool_calls ?? []).map((tc, i) => {
|
||||
const id = tc.id || `tool_${i}_${Date.now()}`;
|
||||
return {
|
||||
id,
|
||||
name: tc.function?.name ?? "",
|
||||
arguments: tc.function?.arguments ?? "",
|
||||
args: this.safeJsonParse(tc.function?.arguments ?? ""),
|
||||
};
|
||||
});
|
||||
return { content: content.trim(), toolCalls };
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON after all — treat as SSE below.
|
||||
}
|
||||
}
|
||||
return this.parseSse(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SSE stream body into accumulated content + any tool_calls.
|
||||
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
pgUserReputationsTable,
|
||||
pgVoiceRecordingsTable,
|
||||
} from "../../shared/index.js";
|
||||
import { tools } from "./chatbot.toolDefs.js";
|
||||
|
||||
/**
|
||||
* Executor for the chatbot's server-watcher tools. The tool *definitions*
|
||||
@@ -430,7 +429,7 @@ async function moderationTimeline(
|
||||
return JSON.stringify(rows);
|
||||
}
|
||||
|
||||
async function corrections(guildId?: string, limit = 10): Promise<string> {
|
||||
async function corrections(_guildId?: string, limit = 10): Promise<string> {
|
||||
const db = getDatabase();
|
||||
const rows = await db
|
||||
.select({
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
pgChannelCulturesTable,
|
||||
pgMessagesTable,
|
||||
pgUserProfilesTable,
|
||||
pgUserReputationsTable,
|
||||
pgVoiceRecordingsTable,
|
||||
} from "../../shared/index.js";
|
||||
import type { ListUsersQuery } from "./dashboard.service.js";
|
||||
@@ -156,8 +155,7 @@ export class DashboardRepository {
|
||||
p.profile_summary,
|
||||
m.total_messages,
|
||||
m.flagged_count,
|
||||
m.last_message_at,
|
||||
r.trust_score
|
||||
m.last_message_at
|
||||
FROM (
|
||||
SELECT
|
||||
user_id,
|
||||
@@ -170,7 +168,6 @@ export class DashboardRepository {
|
||||
GROUP BY user_id, username, avatar_url
|
||||
) m
|
||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
||||
${whereClause}
|
||||
ORDER BY m.last_message_at DESC NULLS LAST
|
||||
LIMIT ${limit + 1}
|
||||
@@ -186,10 +183,6 @@ export class DashboardRepository {
|
||||
total_messages: Number(r.total_messages),
|
||||
flagged_count: Number(r.flagged_count),
|
||||
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
||||
trust_score:
|
||||
r.trust_score !== null && r.trust_score !== undefined
|
||||
? Number(r.trust_score)
|
||||
: null,
|
||||
}));
|
||||
|
||||
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
||||
@@ -437,10 +430,7 @@ export class DashboardRepository {
|
||||
m.flagged_count,
|
||||
m.clean_count,
|
||||
p.profile_summary,
|
||||
p.last_analyzed_at,
|
||||
r.trust_score,
|
||||
r.clean_message_streak,
|
||||
r.total_infractions
|
||||
p.last_analyzed_at
|
||||
FROM (
|
||||
SELECT
|
||||
user_id,
|
||||
@@ -454,7 +444,6 @@ export class DashboardRepository {
|
||||
GROUP BY user_id, username, avatar_url
|
||||
) m
|
||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
||||
`);
|
||||
|
||||
const row = userResult.rows[0] as Record<string, unknown> | undefined;
|
||||
@@ -481,13 +470,6 @@ export class DashboardRepository {
|
||||
last_analyzed_at: row.last_analyzed_at
|
||||
? Number(row.last_analyzed_at)
|
||||
: null,
|
||||
trust_score: row.trust_score != null ? Number(row.trust_score) : null,
|
||||
clean_message_streak:
|
||||
row.clean_message_streak != null
|
||||
? Number(row.clean_message_streak)
|
||||
: null,
|
||||
total_infractions:
|
||||
row.total_infractions != null ? Number(row.total_infractions) : null,
|
||||
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
|
||||
id: String(r.id),
|
||||
content: String(r.content),
|
||||
|
||||
@@ -67,9 +67,9 @@ export const moderationErrors = new Counter({
|
||||
labelNames: ["type"] as const,
|
||||
});
|
||||
|
||||
export const searxngCalls = new Counter({
|
||||
name: "moderation_searxng_calls_total",
|
||||
help: "SearXNG search calls",
|
||||
export const webSearchCalls = new Counter({
|
||||
name: "moderation_websearch_calls_total",
|
||||
help: "Wikipedia web-search calls",
|
||||
labelNames: ["status"] as const,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
|
||||
export interface ChannelCultureRow {
|
||||
channel_id: string;
|
||||
guild_id: string | null;
|
||||
channel_name: string | null;
|
||||
culture_summary: string | null;
|
||||
last_analyzed_at: number | null;
|
||||
}
|
||||
|
||||
export interface GlossaryRow {
|
||||
term: string;
|
||||
definition: string;
|
||||
source_url: string;
|
||||
resolved_at: number;
|
||||
hit_count: number;
|
||||
}
|
||||
|
||||
export interface EditHistoryRow {
|
||||
id: string;
|
||||
message_id: string;
|
||||
old_content: string;
|
||||
edited_at: number;
|
||||
channel_id: string | null;
|
||||
channel_name: string | null;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
export class KnowledgeRepository {
|
||||
/** Public read-only channel culture glossary (AI-generated norms/slang). */
|
||||
async listChannelCultures(limit = 50, search?: string) {
|
||||
const db = getDatabase();
|
||||
const conditions: string[] = [];
|
||||
if (search) {
|
||||
conditions.push(
|
||||
`(c.channel_id ILIKE '%${search.replace(/'/g, "''")}%' OR c.culture_summary ILIKE '%${search.replace(/'/g, "''")}%')`,
|
||||
);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await db.execute(
|
||||
sql.raw(`
|
||||
SELECT
|
||||
c.channel_id,
|
||||
c.guild_id,
|
||||
COALESCE(NULLIF((
|
||||
SELECT (metadata::jsonb -> 'channel' ->> 'channelName')
|
||||
FROM messages WHERE channel_id = c.channel_id AND metadata IS NOT NULL
|
||||
LIMIT 1
|
||||
), ''), c.channel_id) AS channel_name,
|
||||
c.culture_summary,
|
||||
c.last_analyzed_at
|
||||
FROM channel_cultures c
|
||||
${where}
|
||||
ORDER BY c.last_analyzed_at DESC NULLS LAST
|
||||
LIMIT ${limit}
|
||||
`),
|
||||
);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
channel_id: String(r.channel_id),
|
||||
guild_id: r.guild_id ? String(r.guild_id) : null,
|
||||
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||
culture_summary: r.culture_summary ? String(r.culture_summary) : null,
|
||||
last_analyzed_at: r.last_analyzed_at ? Number(r.last_analyzed_at) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Public read-only term knowledge base (resolved via Wikipedia/SearXNG). */
|
||||
async listGlossary(limit = 50, search?: string) {
|
||||
const db = getDatabase();
|
||||
const conditions: string[] = [];
|
||||
if (search) {
|
||||
conditions.push(
|
||||
`(term ILIKE '%${search.replace(/'/g, "''")}%' OR definition ILIKE '%${search.replace(/'/g, "''")}%')`,
|
||||
);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await db.execute(
|
||||
sql.raw(`
|
||||
SELECT term, definition, source_url, resolved_at, hit_count
|
||||
FROM term_glossary_cache
|
||||
${where}
|
||||
ORDER BY hit_count DESC, resolved_at DESC
|
||||
LIMIT ${limit}
|
||||
`),
|
||||
);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
term: String(r.term),
|
||||
definition: String(r.definition ?? ""),
|
||||
source_url: r.source_url ? String(r.source_url) : "",
|
||||
resolved_at: r.resolved_at ? Number(r.resolved_at) : 0,
|
||||
hit_count: Number(r.hit_count ?? 0),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeRepository = new KnowledgeRepository();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { knowledgeRepository } from "./knowledge.repository.js";
|
||||
|
||||
const logger = createChildLogger("knowledge.service");
|
||||
|
||||
export class KnowledgeService {
|
||||
async listChannelCultures(limit = 50, search?: string) {
|
||||
logger.debug({ limit, search }, "Listing channel cultures");
|
||||
return knowledgeRepository.listChannelCultures(limit, search);
|
||||
}
|
||||
|
||||
async listGlossary(limit = 50, search?: string) {
|
||||
logger.debug({ limit, search }, "Listing glossary terms");
|
||||
return knowledgeRepository.listGlossary(limit, search);
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeService = new KnowledgeService();
|
||||
@@ -0,0 +1,43 @@
|
||||
import { config } from "@/shared/config/index";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
|
||||
const logger = createChildLogger("messages-embed");
|
||||
|
||||
/**
|
||||
* Embed a search query with the configured OpenAI-compatible embedding model.
|
||||
* Uses raw fetch (the backend has no openai SDK dependency) and returns null
|
||||
* when embeddings are not configured (search unavailable).
|
||||
*
|
||||
* encoding_format: "float" is REQUIRED — Nvidia-backed models reject base64.
|
||||
*/
|
||||
export async function embedQuery(text: string): Promise<number[] | null> {
|
||||
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
|
||||
try {
|
||||
const res = await fetch(`${config.AI_LLM_BASE_URL}/embeddings`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.AI_LLM_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.AI_LLM_EMBEDDING_MODEL,
|
||||
input: text,
|
||||
encoding_format: "float",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.warn({ status: res.status }, "query embed HTTP error");
|
||||
return null;
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
data?: Array<{ embedding?: number[] }>;
|
||||
};
|
||||
return json.data?.[0]?.embedding ?? null;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"query embed failed",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,71 @@ export class MessagesRepository {
|
||||
return { data, nextCursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Async generator that yields messages ONE AT A TIME for WS streaming.
|
||||
* Each `.next()` runs its own bounded DB query (limit+1) advancing on the
|
||||
* `created_at` cursor, so memory stays flat and the caller can emit one WS
|
||||
* frame per message (no 50-row batch). Stops when a page returns < limit.
|
||||
*/
|
||||
async *streamMany(
|
||||
query: MessageQuery,
|
||||
pageSize = 50,
|
||||
): AsyncGenerator<ReturnType<typeof mapMessageRow>, void, unknown> {
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (query.guildId) {
|
||||
conditions.push(eq(pgMessagesTable.guild_id, query.guildId));
|
||||
}
|
||||
if (query.channelId) {
|
||||
conditions.push(eq(pgMessagesTable.channel_id, query.channelId));
|
||||
}
|
||||
if (query.userId) {
|
||||
conditions.push(eq(pgMessagesTable.user_id, query.userId));
|
||||
}
|
||||
if (query.status) {
|
||||
conditions.push(eq(pgMessagesTable.ai_status, query.status));
|
||||
}
|
||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||
const excludeThreads = or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
);
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
let cursor: string | undefined = query.cursor;
|
||||
|
||||
while (true) {
|
||||
const pageConditions = where ? [where] : [];
|
||||
if (cursor) {
|
||||
pageConditions.push(lt(pgMessagesTable.created_at, Number(cursor)));
|
||||
}
|
||||
const pageWhere =
|
||||
pageConditions.length > 0 ? and(...pageConditions) : undefined;
|
||||
|
||||
const db = getDatabase();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pgMessagesTable)
|
||||
.where(pageWhere)
|
||||
.orderBy(desc(pgMessagesTable.created_at))
|
||||
.limit(pageSize + 1);
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const hasMore = rows.length > pageSize;
|
||||
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
|
||||
|
||||
for (const r of pageRows) {
|
||||
yield mapMessageRow(r as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (!hasMore) return;
|
||||
cursor = String(rows[pageSize - 1].created_at);
|
||||
}
|
||||
}
|
||||
|
||||
async create(data: MessageCreate) {
|
||||
const db = getDatabase();
|
||||
const id = crypto.randomUUID();
|
||||
@@ -394,6 +459,69 @@ export class MessagesRepository {
|
||||
|
||||
return { data: trimmed, nextCursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-hour message volume for the last `days` days, grouped by channel.
|
||||
* Powers the public Activity Heatmap (read-only, no write scope).
|
||||
* Returns a flat list of { channel_id, hour (0-23), count } buckets.
|
||||
*/
|
||||
async getActivity(days = 30) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(sql`
|
||||
SELECT channel_id,
|
||||
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||
COUNT(*)::int AS c
|
||||
FROM messages
|
||||
WHERE created_at >= ${since}
|
||||
GROUP BY channel_id, hour
|
||||
ORDER BY channel_id, hour
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
channelId: String(r.channel_id ?? "unknown"),
|
||||
hour: Number(r.hour ?? 0),
|
||||
count: Number(r.c ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent message edits across the server (evasion-signal tracker).
|
||||
* Public, read-only. Joins message_edits → messages for context.
|
||||
*/
|
||||
async getRecentEdits(limit = 50, channelId?: string) {
|
||||
const db = getDatabase();
|
||||
const where = channelId
|
||||
? `WHERE m.channel_id = '${channelId.replace(/'/g, "''")}'`
|
||||
: "";
|
||||
const result = await db.execute(
|
||||
sql.raw(`
|
||||
SELECT
|
||||
e.id,
|
||||
e.message_id,
|
||||
e.old_content,
|
||||
e.edited_at,
|
||||
m.channel_id,
|
||||
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||
m.username
|
||||
FROM message_edits e
|
||||
JOIN messages m ON m.id = e.message_id
|
||||
${where}
|
||||
ORDER BY e.edited_at DESC
|
||||
LIMIT ${limit}
|
||||
`),
|
||||
);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
message_id: String(r.message_id),
|
||||
old_content: r.old_content ? String(r.old_content) : "",
|
||||
edited_at: r.edited_at ? Number(r.edited_at) : 0,
|
||||
channel_id: r.channel_id ? String(r.channel_id) : null,
|
||||
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||
username: r.username ? String(r.username) : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const messagesRepository = new MessagesRepository();
|
||||
@@ -41,3 +41,11 @@ export const messageUpdateSchema = z.object({
|
||||
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
||||
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
||||
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
||||
|
||||
export const semanticSearchSchema = z.object({
|
||||
query: z.string().min(1).max(500),
|
||||
limit: z.coerce.number().int().positive().max(50).default(10),
|
||||
guildId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SemanticSearchQuery = z.infer<typeof semanticSearchSchema>;
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NotFoundError, ValidationError } from "@/shared/errors/index";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { embedQuery } from "./embed.js";
|
||||
import { messagesRepository } from "./messages.repository.js";
|
||||
import type { MessageQuery } from "./messages.schema.js";
|
||||
import type { MessageQuery, SemanticSearchQuery } from "./messages.schema.js";
|
||||
import { searchArchive } from "./qdrant.js";
|
||||
|
||||
const logger = createChildLogger("messages.service");
|
||||
|
||||
@@ -15,6 +17,14 @@ export class MessagesService {
|
||||
return messagesRepository.findMany(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream messages one at a time (no 50-row batch). The WS handler iterates
|
||||
* this generator and emits one `message_snapshot` frame per message.
|
||||
*/
|
||||
streamMessages(query: MessageQuery, pageSize = 50) {
|
||||
return messagesRepository.streamMany(query, pageSize);
|
||||
}
|
||||
|
||||
async getMessagesByChannel(channelId: string, query: MessageQuery) {
|
||||
if (!channelId) {
|
||||
throw new ValidationError("channelId is required");
|
||||
@@ -70,6 +80,49 @@ export class MessagesService {
|
||||
logger.debug({ channelId, limit }, "Getting review messages");
|
||||
return messagesRepository.getReviewMessages(channelId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public, read-only semantic search over the persistent message archive.
|
||||
* Embeds the query, searches Qdrant, returns text + metadata. Best-effort:
|
||||
* if embeddings/Qdrant are unavailable, returns an empty result set.
|
||||
*/
|
||||
async semanticSearch(
|
||||
input: SemanticSearchQuery,
|
||||
): Promise<{ results: ReturnType<typeof mapSearchHit>[]; nextCursor: null }> {
|
||||
const vector = await embedQuery(input.query);
|
||||
if (!vector) {
|
||||
logger.debug(
|
||||
{ query: input.query },
|
||||
"semantic search skipped: no embedder",
|
||||
);
|
||||
return { results: [], nextCursor: null };
|
||||
}
|
||||
const hits = await searchArchive(vector, input.limit, 0.6);
|
||||
const results = hits.map((h) => mapSearchHit(h));
|
||||
return { results, nextCursor: null };
|
||||
}
|
||||
|
||||
async getActivity(days = 30) {
|
||||
return messagesRepository.getActivity(days);
|
||||
}
|
||||
|
||||
async getRecentEdits(limit = 50, channelId?: string) {
|
||||
logger.debug({ limit, channelId }, "Getting recent message edits");
|
||||
return messagesRepository.getRecentEdits(limit, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape returned to the frontend (text + metadata from the archive payload). */
|
||||
function mapSearchHit(hit: {
|
||||
score: number;
|
||||
payload: { text: string; content_hash?: string; analyzed_at: number };
|
||||
}) {
|
||||
return {
|
||||
message_id: hit.payload.content_hash ?? null,
|
||||
content: hit.payload.text,
|
||||
score: hit.score,
|
||||
created_at: hit.payload.analyzed_at,
|
||||
};
|
||||
}
|
||||
|
||||
export const messagesService = new MessagesService();
|
||||
@@ -0,0 +1,95 @@
|
||||
import { config } from "@/shared/config/index";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
|
||||
const logger = createChildLogger("messages-qdrant");
|
||||
|
||||
export interface ArchiveHit {
|
||||
score: number;
|
||||
payload: {
|
||||
text: string;
|
||||
content_hash?: string;
|
||||
analyzed_at: number;
|
||||
expires_at: number;
|
||||
};
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
return (config.QDRANT_URL ?? "http://100.121.180.82:6333").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
function headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (config.QDRANT_API_KEY) h["api-key"] = config.QDRANT_API_KEY;
|
||||
return h;
|
||||
}
|
||||
|
||||
export const ARCHIVE_COLLECTION =
|
||||
config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
|
||||
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
timeoutMs = 10_000,
|
||||
): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`${baseUrl()}${path}`, {
|
||||
method,
|
||||
headers: headers(),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Qdrant ${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return text ? JSON.parse(text) : null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Search the archive collection for the nearest vectors to `vector`. */
|
||||
export async function searchArchive(
|
||||
vector: number[],
|
||||
limit: number,
|
||||
scoreThreshold: number,
|
||||
): Promise<ArchiveHit[]> {
|
||||
if (!config.QDRANT_URL) return [];
|
||||
try {
|
||||
const json = (await request(
|
||||
"POST",
|
||||
`/collections/${ARCHIVE_COLLECTION}/points/search`,
|
||||
{
|
||||
vector,
|
||||
limit,
|
||||
score_threshold: scoreThreshold,
|
||||
with_payload: true,
|
||||
},
|
||||
)) as {
|
||||
result?: Array<{
|
||||
score?: number;
|
||||
payload?: ArchiveHit["payload"];
|
||||
}>;
|
||||
};
|
||||
return (json.result ?? [])
|
||||
.filter((h) => h.payload?.text)
|
||||
.map((h) => ({
|
||||
score: h.score ?? 0,
|
||||
payload: h.payload as ArchiveHit["payload"],
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"archive search failed",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,20 @@ const ACTION_TYPES = [
|
||||
] as const;
|
||||
const STATUSES = ["pending", "executed", "failed"] as const;
|
||||
|
||||
/** Parse a JSON-stringified array column (e.g. flags/categories/evidence).
|
||||
* Returns null on empty/malformed input so the FE can treat it as "no data". */
|
||||
function parseJsonArray(value: unknown): string[] | null {
|
||||
if (value == null) return null;
|
||||
const str = typeof value === "string" ? value : String(value);
|
||||
if (str.length === 0) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(str);
|
||||
return Array.isArray(parsed) ? (parsed as string[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class ModerationRepository {
|
||||
async getStats() {
|
||||
const db = getDatabase();
|
||||
@@ -103,6 +117,13 @@ export class ModerationRepository {
|
||||
a.error,
|
||||
a.created_at,
|
||||
a.executed_at,
|
||||
a.flags,
|
||||
a.categories,
|
||||
a.severity,
|
||||
a.confidence,
|
||||
a.score,
|
||||
a.evidence,
|
||||
a.policy_version,
|
||||
m.username,
|
||||
LEFT(m.content, 300) AS content
|
||||
FROM moderation_actions a
|
||||
@@ -126,6 +147,13 @@ export class ModerationRepository {
|
||||
error: r.error ? String(r.error) : null,
|
||||
created_at: r.created_at ? Number(r.created_at) : null,
|
||||
executed_at: r.executed_at ? Number(r.executed_at) : null,
|
||||
flags: parseJsonArray(r.flags),
|
||||
categories: parseJsonArray(r.categories),
|
||||
severity: r.severity ? String(r.severity) : null,
|
||||
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||
score: r.score != null ? Number(r.score) : null,
|
||||
evidence: parseJsonArray(r.evidence),
|
||||
policy_version: r.policy_version ? String(r.policy_version) : null,
|
||||
username: r.username ? String(r.username) : null,
|
||||
content: r.content ? String(r.content) : null,
|
||||
}));
|
||||
@@ -136,6 +164,220 @@ export class ModerationRepository {
|
||||
|
||||
return { data, nextCursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate moderation trends over the last `days` days.
|
||||
* - category counts (from the jsonb/text[] `categories` column, unnested)
|
||||
* - severity distribution
|
||||
* - action_type distribution
|
||||
* Read-only; powers the public Toxic Topic Trends panel.
|
||||
*/
|
||||
async getTrends(days: number) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
|
||||
const cats = await db.execute(sql`
|
||||
SELECT jsonb_array_elements_text(a.categories::jsonb) AS cat, COUNT(*)::int AS c
|
||||
FROM moderation_actions a
|
||||
WHERE a.created_at >= ${since} AND a.categories IS NOT NULL AND a.categories != '[]' AND a.categories != ''
|
||||
GROUP BY cat
|
||||
ORDER BY c DESC
|
||||
LIMIT 15
|
||||
`);
|
||||
const catRows = (cats.rows as Record<string, unknown>[]) || [];
|
||||
|
||||
const sev = await db.execute(sql`
|
||||
SELECT severity, COUNT(*)::int AS c
|
||||
FROM moderation_actions
|
||||
WHERE created_at >= ${since} AND severity IS NOT NULL
|
||||
GROUP BY severity
|
||||
`);
|
||||
const sevRows = (sev.rows as Record<string, unknown>[]) || [];
|
||||
|
||||
const act = await db.execute(sql`
|
||||
SELECT action_type, COUNT(*)::int AS c
|
||||
FROM moderation_actions
|
||||
WHERE created_at >= ${since}
|
||||
GROUP BY action_type
|
||||
ORDER BY c DESC
|
||||
`);
|
||||
const actRows = (act.rows as Record<string, unknown>[]) || [];
|
||||
|
||||
return {
|
||||
categories: catRows.map((r) => ({
|
||||
name: String(r.cat),
|
||||
count: Number(r.c ?? 0),
|
||||
})),
|
||||
severities: sevRows.map((r) => ({
|
||||
level: String(r.severity),
|
||||
count: Number(r.c ?? 0),
|
||||
})),
|
||||
actions: actRows.map((r) => ({
|
||||
type: String(r.action_type),
|
||||
count: Number(r.c ?? 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Top flagged domains over the last `days` days.
|
||||
* Extracts the host from any URL in `content`/`reason`/`evidence` and ranks
|
||||
* by how often it appears in moderation actions. Powers the Scam Domain panel.
|
||||
*/
|
||||
async getTopFlaggedDomains(days: number) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(sql`
|
||||
SELECT host, COUNT(*)::int AS c
|
||||
FROM (
|
||||
SELECT DISTINCT a.id,
|
||||
(regexp_matches(COALESCE(a.content,'') || ' ' || COALESCE(a.reason,'') || ' ' || COALESCE(a.evidence,''), 'https?://([^/\s?#]+)', 'g'))[1] AS host
|
||||
FROM moderation_actions a
|
||||
WHERE a.created_at >= ${since}
|
||||
AND (a.content IS NOT NULL OR a.reason IS NOT NULL OR a.evidence IS NOT NULL)
|
||||
) sub
|
||||
WHERE host IS NOT NULL
|
||||
GROUP BY host
|
||||
ORDER BY c DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
domain: String(r.host).toLowerCase(),
|
||||
count: Number(r.c ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Top flagged channels over the last `days` days.
|
||||
* Joins moderation_actions → messages to attribute each action to a channel.
|
||||
* Powers the Top Flagged Channels panel.
|
||||
*/
|
||||
async getTopFlaggedChannels(days: number) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
m.channel_id,
|
||||
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||
COUNT(*)::int AS flagged_count
|
||||
FROM moderation_actions a
|
||||
LEFT JOIN messages m ON m.id = a.message_id
|
||||
WHERE a.created_at >= ${since} AND m.channel_id IS NOT NULL
|
||||
GROUP BY m.channel_id, (m.metadata::jsonb -> 'channel' ->> 'channelName')
|
||||
ORDER BY flagged_count DESC
|
||||
LIMIT 15
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
channel_id: String(r.channel_id),
|
||||
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||
flagged_count: Number(r.flagged_count),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hour-of-day distribution of moderation actions over the last `days` days.
|
||||
* 24 rows (hour 0..23), with total + flagged-by-severity counts.
|
||||
* Powers the Moderation Heatmap by Hour panel.
|
||||
*/
|
||||
async getHourlyModeration(days: number) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||
COUNT(*)::int AS total
|
||||
FROM moderation_actions
|
||||
WHERE created_at >= ${since}
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
const byHour = new Map<number, number>();
|
||||
for (const r of rows) byHour.set(Number(r.hour), Number(r.total));
|
||||
return Array.from({ length: 24 }, (_, h) => ({
|
||||
hour: h,
|
||||
total: byHour.get(h) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Moderation actions filtered to a single category (drill-down).
|
||||
* Powers the Flag Category Drill-down panel.
|
||||
*/
|
||||
async getByCategory(days: number, category: string, limit = 50) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(
|
||||
sql.raw(`
|
||||
SELECT
|
||||
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,
|
||||
m.username, LEFT(m.content, 300) AS content
|
||||
FROM moderation_actions a
|
||||
LEFT JOIN messages m ON m.id = a.message_id
|
||||
WHERE a.created_at >= ${since}
|
||||
AND a.categories IS NOT NULL
|
||||
AND a.categories::jsonb @> ${JSON.stringify([category])}::jsonb
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ${limit}
|
||||
`),
|
||||
);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id ?? ""),
|
||||
message_id: r.message_id ? String(r.message_id) : null,
|
||||
user_id: r.user_id ? String(r.user_id) : null,
|
||||
guild_id: String(r.guild_id ?? ""),
|
||||
action_type: String(r.action_type ?? "unknown"),
|
||||
reason: r.reason ? String(r.reason) : null,
|
||||
status: String(r.status ?? "unknown"),
|
||||
created_at: r.created_at ? Number(r.created_at) : null,
|
||||
severity: r.severity ? String(r.severity) : null,
|
||||
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||
score: r.score != null ? Number(r.score) : null,
|
||||
username: r.username ? String(r.username) : null,
|
||||
content: r.content ? String(r.content) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-moderation coverage over the last `days` days.
|
||||
* Run completion rate from ai_analysis_runs — what fraction of analysis runs
|
||||
* completed (vs failed/pending). Public "how much is automated" trust metric.
|
||||
*/
|
||||
async getCoverage(days: number) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(sql`
|
||||
SELECT status, COUNT(*)::int AS c
|
||||
FROM ai_analysis_runs
|
||||
WHERE created_at >= ${since}
|
||||
GROUP BY status
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
const counts: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
const s = String(r.status);
|
||||
const c = Number(r.c ?? 0);
|
||||
counts[s] = c;
|
||||
total += c;
|
||||
}
|
||||
const completed = counts.completed ?? 0;
|
||||
const failed = counts.failed ?? 0;
|
||||
const pending = (counts.pending ?? 0) + (counts.processing ?? 0);
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
failed,
|
||||
pending,
|
||||
coverage_rate:
|
||||
total > 0 ? Number(((completed / total) * 100).toFixed(1)) : 0,
|
||||
failed_rate: total > 0 ? Number(((failed / total) * 100).toFixed(1)) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const moderationRepository = new ModerationRepository();
|
||||
@@ -8,10 +8,33 @@ const logger = createChildLogger("moderation.service");
|
||||
|
||||
export class ModerationService {
|
||||
async getStats() {
|
||||
logger.debug("Fetching moderation stats");
|
||||
return moderationRepository.getStats();
|
||||
}
|
||||
|
||||
async getTrends(days = 30) {
|
||||
return moderationRepository.getTrends(days);
|
||||
}
|
||||
|
||||
async getTopFlaggedDomains(days = 30) {
|
||||
return moderationRepository.getTopFlaggedDomains(days);
|
||||
}
|
||||
|
||||
async getTopFlaggedChannels(days = 30) {
|
||||
return moderationRepository.getTopFlaggedChannels(days);
|
||||
}
|
||||
|
||||
async getHourlyModeration(days = 30) {
|
||||
return moderationRepository.getHourlyModeration(days);
|
||||
}
|
||||
|
||||
async getByCategory(days = 30, category: string) {
|
||||
return moderationRepository.getByCategory(days, category);
|
||||
}
|
||||
|
||||
async getCoverage(days = 30) {
|
||||
return moderationRepository.getCoverage(days);
|
||||
}
|
||||
|
||||
async listActions(query: ListModerationQuery) {
|
||||
logger.debug({ query }, "Listing moderation actions");
|
||||
return moderationRepository.listActions(query);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { analysisService } from "../modules/analysis/analysis.service";
|
||||
import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
|
||||
import { chatbotService } from "../modules/chatbot/chatbot.service";
|
||||
// ── Service imports ──────────────────────────────────────────────
|
||||
import { dashboardService } from "../modules/dashboard/dashboard.service";
|
||||
import { knowledgeService } from "../modules/knowledge/knowledge.service";
|
||||
import {
|
||||
mediaLoopSchema,
|
||||
mediaQueueSchema,
|
||||
@@ -15,7 +16,10 @@ import {
|
||||
skip,
|
||||
stop,
|
||||
} from "../modules/media/media.service";
|
||||
import { messageQuerySchema } from "../modules/messages/messages.schema";
|
||||
import {
|
||||
messageQuerySchema,
|
||||
semanticSearchSchema,
|
||||
} from "../modules/messages/messages.schema";
|
||||
import { messagesService } from "../modules/messages/messages.service";
|
||||
import { moderationService } from "../modules/moderation/moderation.service";
|
||||
import { recordingsService } from "../modules/recordings/recordings.service";
|
||||
@@ -30,17 +34,16 @@ import {
|
||||
} from "../modules/voice/voice.service";
|
||||
import { config } from "../shared/config/index";
|
||||
import { publishCommandNoReply } from "../shared/redis/index";
|
||||
import { logger, publicProcedure, router } from "./trpc";
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────────────
|
||||
const dashboardRouter = router({
|
||||
stats: publicProcedure.query(() => dashboardService.getStats()),
|
||||
activity: publicProcedure
|
||||
const dashboardRouter = {
|
||||
stats: os.handler(() => dashboardService.getStats()),
|
||||
activity: os
|
||||
.input(
|
||||
z.object({ days: z.coerce.number().int().min(1).max(90).default(14) }),
|
||||
)
|
||||
.query(({ input }) => dashboardService.getActivity(input.days)),
|
||||
users: publicProcedure
|
||||
.handler(({ input }) => dashboardService.getActivity(input.days)),
|
||||
users: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(20),
|
||||
@@ -48,17 +51,17 @@ const dashboardRouter = router({
|
||||
search: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
.handler(({ input }) =>
|
||||
dashboardService.listUsers({
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
search: input.search,
|
||||
}),
|
||||
),
|
||||
userDetail: publicProcedure
|
||||
userDetail: os
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.query(({ input }) => dashboardService.getUserDetail(input.userId)),
|
||||
channels: publicProcedure
|
||||
.handler(({ input }) => dashboardService.getUserDetail(input.userId)),
|
||||
channels: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(20),
|
||||
@@ -66,82 +69,105 @@ const dashboardRouter = router({
|
||||
guildId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
.handler(({ input }) =>
|
||||
dashboardService.listChannels({
|
||||
limit: input.limit,
|
||||
search: input.search,
|
||||
guildId: input.guildId,
|
||||
}),
|
||||
),
|
||||
channelDetail: publicProcedure
|
||||
channelDetail: os
|
||||
.input(z.object({ channelId: z.string() }))
|
||||
.query(({ input }) => dashboardService.getChannelDetail(input.channelId)),
|
||||
reactions: publicProcedure
|
||||
.handler(({ input }) => dashboardService.getChannelDetail(input.channelId)),
|
||||
reactions: os
|
||||
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
||||
.query(({ input }) => dashboardService.getTopReactions(input.limit)),
|
||||
reactors: publicProcedure
|
||||
.handler(({ input }) => dashboardService.getTopReactions(input.limit)),
|
||||
reactors: os
|
||||
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
||||
.query(({ input }) => dashboardService.getTopReactors(input.limit)),
|
||||
});
|
||||
.handler(({ input }) => dashboardService.getTopReactors(input.limit)),
|
||||
};
|
||||
|
||||
// ── Messages ─────────────────────────────────────────────────────
|
||||
const messagesRouter = router({
|
||||
list: publicProcedure
|
||||
const messagesRouter = {
|
||||
list: os
|
||||
.input(messageQuerySchema)
|
||||
.query(({ input }) => messagesService.listMessages(input)),
|
||||
byChannel: publicProcedure
|
||||
.handler(({ input }) => messagesService.listMessages(input)),
|
||||
byChannel: os
|
||||
.input(
|
||||
z.object({
|
||||
channelId: z.string(),
|
||||
query: messageQuerySchema,
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
.handler(({ input }) =>
|
||||
messagesService.getMessagesByChannel(input.channelId, input.query),
|
||||
),
|
||||
detail: publicProcedure
|
||||
detail: os
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(({ input }) => messagesService.getMessageById(input.id)),
|
||||
images: publicProcedure
|
||||
.handler(({ input }) => messagesService.getMessageById(input.id)),
|
||||
images: os
|
||||
.input(
|
||||
z.object({
|
||||
guildId: z.string(),
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
.handler(({ input }) =>
|
||||
messagesService.getImageMessages(input.guildId, input.limit),
|
||||
),
|
||||
attachmentsByChannel: publicProcedure
|
||||
attachmentsByChannel: os
|
||||
.input(
|
||||
z.object({
|
||||
channelId: z.string(),
|
||||
query: messageQuerySchema,
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
.handler(({ input }) =>
|
||||
messagesService.getAttachmentsByChannel(input.channelId, input.query),
|
||||
),
|
||||
review: publicProcedure
|
||||
review: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(20),
|
||||
channelId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.handler(async ({ input }) => {
|
||||
const rows = await messagesService.getReviewMessages(
|
||||
input.channelId,
|
||||
input.limit,
|
||||
);
|
||||
return { results: rows, limit: input.limit, cursor: null };
|
||||
}),
|
||||
});
|
||||
// Public, read-only semantic search over the message archive.
|
||||
semanticSearch: os
|
||||
.input(semanticSearchSchema)
|
||||
.handler(({ input }) => messagesService.semanticSearch(input)),
|
||||
// Public, read-only activity heatmap data (per-hour volume by channel).
|
||||
activity: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) => messagesService.getActivity(input.days)),
|
||||
// Public, read-only recent message edits (evasion tracker).
|
||||
editHistory: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
channelId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) =>
|
||||
messagesService.getRecentEdits(input.limit, input.channelId),
|
||||
),
|
||||
};
|
||||
|
||||
// ── Moderation ───────────────────────────────────────────────────
|
||||
const moderationRouter = router({
|
||||
stats: publicProcedure.query(() => moderationService.getStats()),
|
||||
actions: publicProcedure
|
||||
const moderationRouter = {
|
||||
stats: os.handler(() => moderationService.getStats()),
|
||||
actions: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
@@ -150,7 +176,7 @@ const moderationRouter = router({
|
||||
cursor: z.coerce.number().int().optional(),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
.handler(({ input }) =>
|
||||
moderationService.listActions({
|
||||
limit: input.limit,
|
||||
status: input.status,
|
||||
@@ -158,60 +184,112 @@ const moderationRouter = router({
|
||||
cursor: input.cursor,
|
||||
}),
|
||||
),
|
||||
});
|
||||
trends: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) => moderationService.getTrends(input.days)),
|
||||
// Flagged link / scam domain ranking (public Scam Domain panel).
|
||||
topDomains: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) => moderationService.getTopFlaggedDomains(input.days)),
|
||||
// Top flagged channels (join moderation_actions → messages).
|
||||
topChannels: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) =>
|
||||
moderationService.getTopFlaggedChannels(input.days),
|
||||
),
|
||||
// Hour-of-day moderation distribution (heatmap by hour).
|
||||
byHour: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) => moderationService.getHourlyModeration(input.days)),
|
||||
// Flag category drill-down (list actions for one category).
|
||||
byCategory: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
category: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) =>
|
||||
moderationService.getByCategory(input.days, input.category),
|
||||
),
|
||||
// Auto-moderation coverage (analysis run completion rate).
|
||||
coverage: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) => moderationService.getCoverage(input.days)),
|
||||
};
|
||||
|
||||
// ── Media ────────────────────────────────────────────────────────
|
||||
const mediaRouter = router({
|
||||
status: publicProcedure.query(() => getStatus()),
|
||||
queue: publicProcedure.input(mediaQueueSchema).mutation(async ({ input }) => {
|
||||
const mediaRouter = {
|
||||
status: os.handler(() => getStatus()),
|
||||
queue: os.input(mediaQueueSchema).handler(async ({ input }) => {
|
||||
await queue(input.source, input.mode);
|
||||
return getStatus();
|
||||
}),
|
||||
skip: publicProcedure.mutation(async () => {
|
||||
skip: os.handler(async () => {
|
||||
await skip();
|
||||
return getStatus();
|
||||
}),
|
||||
stop: publicProcedure.mutation(async () => {
|
||||
stop: os.handler(async () => {
|
||||
await stop();
|
||||
return getStatus();
|
||||
}),
|
||||
loop: publicProcedure.input(mediaLoopSchema).mutation(async ({ input }) => {
|
||||
loop: os.input(mediaLoopSchema).handler(async ({ input }) => {
|
||||
await setLoop(input.loop);
|
||||
return getStatus();
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// ── Voice ─────────────────────────────────────────────────────────
|
||||
const voiceRouter = router({
|
||||
guilds: publicProcedure.query(() => getGuilds()),
|
||||
textChannels: publicProcedure
|
||||
const voiceRouter = {
|
||||
guilds: os.handler(() => getGuilds()),
|
||||
textChannels: os
|
||||
.input(z.object({ guildId: z.string() }))
|
||||
.query(({ input }) => getTextChannels(input.guildId)),
|
||||
voiceChannels: publicProcedure
|
||||
.handler(({ input }) => getTextChannels(input.guildId)),
|
||||
voiceChannels: os
|
||||
.input(z.object({ guildId: z.string() }))
|
||||
.query(({ input }) => getVoiceChannels(input.guildId)),
|
||||
status: publicProcedure.query(() => getVoiceStatus()),
|
||||
connect: publicProcedure
|
||||
.handler(({ input }) => getVoiceChannels(input.guildId)),
|
||||
status: os.handler(() => getVoiceStatus()),
|
||||
connect: os
|
||||
.input(z.object({ guildId: z.string(), channelId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
.handler(async ({ input }) => {
|
||||
await connectVoice(input.guildId, input.channelId);
|
||||
return getVoiceStatus();
|
||||
}),
|
||||
disconnect: publicProcedure.mutation(async () => {
|
||||
disconnect: os.handler(async () => {
|
||||
await disconnectVoice();
|
||||
return getVoiceStatus();
|
||||
}),
|
||||
command: publicProcedure
|
||||
command: os
|
||||
.input(z.object({ command: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
.handler(async ({ input }) => {
|
||||
await publishCommandNoReply(input.command);
|
||||
return { success: true, command: input.command };
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// ── Recordings ───────────────────────────────────────────────────
|
||||
const recordingsRouter = router({
|
||||
list: publicProcedure
|
||||
const recordingsRouter = {
|
||||
list: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
@@ -220,24 +298,22 @@ const recordingsRouter = router({
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
.handler(({ input }) =>
|
||||
recordingsService.getRecent(input.limit, {
|
||||
channelId: input.channelId,
|
||||
userId: input.userId,
|
||||
cursor: input.cursor,
|
||||
}),
|
||||
),
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await recordingsService.deleteById(input.id);
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => {
|
||||
await recordingsService.deleteById(input.id);
|
||||
return { ok: true };
|
||||
}),
|
||||
};
|
||||
|
||||
// ── Analysis (search) ──────────────────────────────────────────────
|
||||
const analysisRouter = router({
|
||||
search: publicProcedure
|
||||
const analysisRouter = {
|
||||
search: os
|
||||
.input(
|
||||
z.object({
|
||||
q: z.string().default(""),
|
||||
@@ -245,18 +321,18 @@ const analysisRouter = router({
|
||||
limit: z.coerce.number().int().positive().default(20),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) =>
|
||||
.handler(({ input }) =>
|
||||
analysisService.search({
|
||||
q: input.q,
|
||||
channelId: input.channelId,
|
||||
limit: input.limit,
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// ── Chatbot ───────────────────────────────────────────────────────
|
||||
const chatbotRouter = router({
|
||||
chat: publicProcedure
|
||||
const chatbotRouter = {
|
||||
chat: os
|
||||
.input(
|
||||
chatRequestSchema.extend({
|
||||
// Per-device actor id; the old REST layer used an X-User-Id header.
|
||||
@@ -264,7 +340,7 @@ const chatbotRouter = router({
|
||||
userId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
.handler(async ({ input }) => {
|
||||
const userId = input.userId ?? "anonymous";
|
||||
const response = await chatbotService.processMessage(
|
||||
input.message,
|
||||
@@ -280,30 +356,52 @@ const chatbotRouter = router({
|
||||
});
|
||||
return { response, timestamp: new Date().toISOString() };
|
||||
}),
|
||||
history: publicProcedure
|
||||
history: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||
userId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
.handler(async ({ input }) => {
|
||||
const userId = input.userId ?? "anonymous";
|
||||
const history = await chatbotService.getChatHistory(userId, input.limit);
|
||||
return { history, total: history.length };
|
||||
}),
|
||||
clearHistory: publicProcedure
|
||||
clearHistory: os
|
||||
.input(z.object({ userId: z.string().optional() }))
|
||||
.mutation(async ({ input }) => {
|
||||
.handler(async ({ input }) => {
|
||||
const userId = input.userId ?? "anonymous";
|
||||
await chatbotService.clearChatHistory(userId);
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// ── Config (public dashboard config snapshot) ──────────────────────
|
||||
const configRouter = router({
|
||||
get: publicProcedure.query(() => ({
|
||||
// ── Knowledge (public read-only culture glossary + term KB) ───────
|
||||
const knowledgeRouter = {
|
||||
channelCultures: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
search: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) =>
|
||||
knowledgeService.listChannelCultures(input.limit, input.search),
|
||||
),
|
||||
glossary: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
search: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) =>
|
||||
knowledgeService.listGlossary(input.limit, input.search),
|
||||
),
|
||||
};
|
||||
const configRouter = {
|
||||
get: os.handler(() => ({
|
||||
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
||||
webserverPort: config.WEBSERVER_PORT,
|
||||
nodeEnv: config.NODE_ENV,
|
||||
@@ -318,18 +416,18 @@ const configRouter = router({
|
||||
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
||||
logLevel: config.LOG_LEVEL,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
// ── UI State ──────────────────────────────────────────────────────
|
||||
const uiStateRouter = router({
|
||||
get: publicProcedure.query(() => uiStateService.getState()),
|
||||
update: publicProcedure
|
||||
const uiStateRouter = {
|
||||
get: os.handler(() => uiStateService.getState()),
|
||||
update: os
|
||||
.input(z.record(z.string(), z.unknown()))
|
||||
.mutation(({ input }) => uiStateService.updateState(input)),
|
||||
});
|
||||
.handler(({ input }) => uiStateService.updateState(input)),
|
||||
};
|
||||
|
||||
// ── Root router ───────────────────────────────────────────────────
|
||||
export const appRouter = router({
|
||||
export const appRouter = {
|
||||
dashboard: dashboardRouter,
|
||||
messages: messagesRouter,
|
||||
moderation: moderationRouter,
|
||||
@@ -340,8 +438,7 @@ export const appRouter = router({
|
||||
chatbot: chatbotRouter,
|
||||
config: configRouter,
|
||||
uiState: uiStateRouter,
|
||||
});
|
||||
knowledge: knowledgeRouter,
|
||||
};
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
logger.info("tRPC appRouter constructed");
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { IncomingMessage, Server } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { applyWSSHandler } from "@trpc/server/adapters/ws";
|
||||
import { onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/ws";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { appRouter } from "./routers";
|
||||
import { appRouter } from "./router";
|
||||
|
||||
const logger = createChildLogger("trpc.ws");
|
||||
const logger = createChildLogger("orpc.ws");
|
||||
|
||||
/**
|
||||
* Attach the tRPC WebSocket handler to the shared HTTP server, on a path
|
||||
* Attach the oRPC WebSocket handler to the shared HTTP server, on a path
|
||||
* SEPARATE from the voice/binary WebSocket (`/ws`). All structured data RPCs
|
||||
* (dashboard, messages, moderation, media, voice control, recordings,
|
||||
* analysis, chatbot, config, ui-state) flow over this `/trpc` socket; the
|
||||
@@ -21,27 +22,22 @@ const logger = createChildLogger("trpc.ws");
|
||||
* other server's path. Routing the upgrade ourselves by URL keeps `/trpc`
|
||||
* and `/ws` fully isolated.
|
||||
*/
|
||||
export function createTRPCWebSocketServer(server: Server): WebSocketServer {
|
||||
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
||||
|
||||
applyWSSHandler({
|
||||
wss,
|
||||
prefix: "/trpc",
|
||||
router: appRouter,
|
||||
createContext: (opts) => ({ conn: opts.res }),
|
||||
keepAlive: { enabled: true, pingMs: 30_000, pongWaitMs: 10_000 },
|
||||
onError: (err) => {
|
||||
logger.error({ err }, "tRPC WS error");
|
||||
},
|
||||
export function createORPCWebSocketServer(server: Server): WebSocketServer {
|
||||
const handler = new RPCHandler(appRouter, {
|
||||
interceptors: [
|
||||
onError((error) => logger.error({ error }, "oRPC WS error")),
|
||||
],
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
||||
|
||||
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||
if (!req.url?.startsWith("/trpc")) return; // let the /ws server handle it
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
handler.upgrade(ws, { context: {} });
|
||||
});
|
||||
});
|
||||
|
||||
logger.info({ path: "/trpc" }, "tRPC WebSocket server attached");
|
||||
logger.info({ path: "/trpc" }, "oRPC WebSocket server attached");
|
||||
return wss;
|
||||
}
|
||||
@@ -134,6 +134,7 @@ export const configSchema = z
|
||||
.default("https://9router.asepharyana.my.id/v1"),
|
||||
AI_LLM_MODEL: z.string().default("text"),
|
||||
AI_LLM_VISION_MODEL: z.string().optional(),
|
||||
AI_LLM_EMBEDDING_MODEL: z.string().optional(),
|
||||
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
||||
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
||||
.number()
|
||||
@@ -208,6 +209,11 @@ export const configSchema = z
|
||||
.default("https://api.openai.com/v1"),
|
||||
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
||||
|
||||
// ── Qdrant (message archive for semantic search) ──────────────────
|
||||
QDRANT_URL: z.string().optional(),
|
||||
QDRANT_API_KEY: z.string().optional(),
|
||||
QDRANT_ARCHIVE_COLLECTION: z.string().default("gmw_message_archive"),
|
||||
|
||||
// ── Auto Delete ─────────────────────────────────────────────────────
|
||||
AUTO_DELETE_FLAGGED_ENABLED: z
|
||||
.string()
|
||||
|
||||
@@ -30,6 +30,7 @@ export const DISCORD_CHANNEL_TOPIC_UPDATED = "discord:channel:topic_updated";
|
||||
export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated";
|
||||
export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added";
|
||||
export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed";
|
||||
export const DISCORD_MODERATION_ACTION = "discord:moderation:action";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command channels (backend -> discord-gateway)
|
||||
@@ -126,4 +127,5 @@ export const DISCORD_CHANNEL_TO_WS_EVENT: Record<string, string> = {
|
||||
[DISCORD_PRESENCE_UPDATED]: "presence_updated",
|
||||
[DISCORD_GUILD_MEMBER_ADDED]: "guild_member_added",
|
||||
[DISCORD_GUILD_MEMBER_REMOVED]: "guild_member_removed",
|
||||
[DISCORD_MODERATION_ACTION]: "moderation_action",
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import { initTRPC } from "@trpc/server";
|
||||
import type { WebSocket } from "ws";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
|
||||
const logger = createChildLogger("trpc");
|
||||
|
||||
/**
|
||||
* tRPC context. The WebSocket transport enriches each request with the raw
|
||||
* socket so procedures can, if needed, inspect connection metadata. The
|
||||
* dashboard is public (no auth), mirroring the previous REST layer.
|
||||
*/
|
||||
export interface TRPCContext {
|
||||
conn: WebSocket | null;
|
||||
}
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create({
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
// Surface a stable code + message for client-side handling.
|
||||
code: error.code,
|
||||
stack: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
// Re-export so routers can import z from one place if desired.
|
||||
export { z } from "zod";
|
||||
export { logger };
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IncomingMessage, Server } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { messagesService } from "../modules/messages/messages.service.js";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
@@ -140,6 +141,73 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
||||
);
|
||||
});
|
||||
|
||||
// Stream historical messages one-by-one over WS (no 50-row batch).
|
||||
// The frontend requests it once per channel switch; the backend emits one
|
||||
// `message_snapshot` frame per message so the UI renders progressively.
|
||||
jsonHandlers.set("stream_messages", async (ws, message) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = (message.payload ?? {}) as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
};
|
||||
const guildId = payload.guildId;
|
||||
const channelId = payload.channelId;
|
||||
if (!guildId && !channelId) {
|
||||
logger.warn({ payload }, "stream_messages requires guildId or channelId");
|
||||
return;
|
||||
}
|
||||
|
||||
const pageSize = 50; // internal DB page size; still emitted one frame at a time
|
||||
const maxFrames = Math.min(payload.limit ?? 200, 500);
|
||||
|
||||
let sent = 0;
|
||||
let nextCursor: string | null = null;
|
||||
try {
|
||||
for await (const msg of messagesService.streamMessages(
|
||||
{
|
||||
guildId,
|
||||
channelId,
|
||||
cursor: payload.cursor,
|
||||
} as never,
|
||||
pageSize,
|
||||
)) {
|
||||
if (ws.readyState !== WebSocket.OPEN) break;
|
||||
// Streamed DESC (newest first); the oldest emitted carries the smallest
|
||||
// created_at, which is exactly the next-page cursor for "load older".
|
||||
const createdAt = (msg as { created_at?: number }).created_at;
|
||||
if (createdAt !== undefined) nextCursor = String(createdAt);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "message_snapshot",
|
||||
data: msg,
|
||||
}),
|
||||
);
|
||||
sent++;
|
||||
if (sent >= maxFrames) break;
|
||||
}
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "message_snapshot_end",
|
||||
data: { sent, nextCursor },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "stream_messages failed");
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "message_snapshot_end",
|
||||
data: { sent, nextCursor, error: true },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wss.on("connection", (ws: WebSocket, req) => {
|
||||
// Parse auth token from query string
|
||||
const rawUrl = req.url ?? "/";
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("chatbot tool definitions", () => {
|
||||
] as const) {
|
||||
const tool = byName.get(name);
|
||||
expect(tool, `missing tool ${name}`).toBeDefined();
|
||||
expect(tool!.function.parameters.required).toContain(required);
|
||||
expect(tool?.function.parameters.required).toContain(required);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Lock the contract that the WS `stream_messages` handler + frontend
|
||||
* `useMessagesStream` depend on.
|
||||
*
|
||||
* Real behavior (src/modules/messages/messages.repository.ts → streamMany, and
|
||||
* src/ws/server.ts stream_messages handler):
|
||||
* - ONE `stream_messages` request streams the WHOLE history for the scope,
|
||||
* internally paging `limit+1` at a time (cursor = oldest created_at of the
|
||||
* page) until exhausted or maxFrames is hit.
|
||||
* - Messages are emitted ONE AT A TIME, DESC (newest first).
|
||||
* - The final `message_snapshot_end` carries `nextCursor` = the OLDEST emitted
|
||||
* row's `created_at`, so the FE's next "load older" request pages forward.
|
||||
*
|
||||
* We replicate streamMany's pagination algorithm over an in-memory array so the
|
||||
* test needs no DB.
|
||||
*/
|
||||
|
||||
type Row = { id: string; created_at: number; guild_id: string };
|
||||
|
||||
function makeStream(
|
||||
rows: Row[],
|
||||
query: { guildId?: string; channelId?: string; cursor?: string },
|
||||
pageSize = 50,
|
||||
): () => Generator<Row, void, unknown> {
|
||||
return function* () {
|
||||
let cursor = query.cursor;
|
||||
while (true) {
|
||||
const page = rows
|
||||
.filter((r) => (query.guildId ? r.guild_id === query.guildId : true))
|
||||
.filter((r) => (cursor ? r.created_at < Number(cursor) : true))
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.slice(0, pageSize + 1);
|
||||
|
||||
if (page.length === 0) return;
|
||||
const hasMore = page.length > pageSize;
|
||||
const pageRows = hasMore ? page.slice(0, pageSize) : page;
|
||||
for (const r of pageRows) yield r;
|
||||
if (!hasMore) return;
|
||||
cursor = String(page[pageSize - 1].created_at);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function streamAll(
|
||||
rows: Row[],
|
||||
query: { guildId?: string; channelId?: string; cursor?: string },
|
||||
pageSize = 50,
|
||||
maxFrames = Infinity,
|
||||
): { data: Row[]; nextCursor: string | null } {
|
||||
const data: Row[] = [];
|
||||
let nextCursor: string | null = null;
|
||||
for (const r of makeStream(rows, query, pageSize)()) {
|
||||
nextCursor = String(r.created_at);
|
||||
data.push(r);
|
||||
if (data.length >= maxFrames) break;
|
||||
}
|
||||
return { data, nextCursor };
|
||||
}
|
||||
|
||||
const mk = (id: string, created_at: number, guild_id = "g1"): Row => ({
|
||||
id,
|
||||
created_at,
|
||||
guild_id,
|
||||
});
|
||||
|
||||
describe("messages.streamMany contract", () => {
|
||||
it("emits newest-first and sets nextCursor to oldest created_at", () => {
|
||||
const rows = [mk("a", 300), mk("b", 200), mk("c", 100)];
|
||||
const { data, nextCursor } = streamAll(rows, { guildId: "g1" });
|
||||
expect(data.map((r) => r.id)).toEqual(["a", "b", "c"]);
|
||||
expect(nextCursor).toBe("100"); // oldest emitted
|
||||
});
|
||||
|
||||
it("streams the entire history in one request, one frame at a time", () => {
|
||||
// 120 rows; one request must yield all 120 (no 50-row batch boundary).
|
||||
const rows = Array.from({ length: 120 }, (_, i) => mk(`m${i}`, 1000 - i));
|
||||
const { data, nextCursor } = streamAll(rows, { guildId: "g1" }, 50);
|
||||
expect(data).toHaveLength(120);
|
||||
expect(data[0].id).toBe("m0"); // newest first
|
||||
expect(nextCursor).toBe("881"); // oldest = m119 (1000-119)
|
||||
});
|
||||
|
||||
it("honors a frame cap and leaves nextCursor mid-history", () => {
|
||||
const rows = Array.from({ length: 120 }, (_, i) => mk(`m${i}`, 1000 - i));
|
||||
const { data, nextCursor } = streamAll(rows, { guildId: "g1" }, 50, 60);
|
||||
expect(data).toHaveLength(60);
|
||||
// nextCursor = 60th oldest = m59 (1000-59=941)
|
||||
expect(nextCursor).toBe("941");
|
||||
});
|
||||
|
||||
it("paginates correctly across subsequent load-older requests", () => {
|
||||
const rows = Array.from({ length: 120 }, (_, i) => mk(`m${i}`, 1000 - i));
|
||||
const first = streamAll(rows, { guildId: "g1" }, 50, 50);
|
||||
expect(first.data).toHaveLength(50);
|
||||
expect(first.nextCursor).toBe("951"); // 50th oldest = m49
|
||||
|
||||
const older = streamAll(
|
||||
rows,
|
||||
{ guildId: "g1", cursor: first.nextCursor ?? undefined },
|
||||
50,
|
||||
50,
|
||||
);
|
||||
expect(older.data[0].id).toBe("m50"); // continues right after m49
|
||||
expect(older.nextCursor).toBe("901"); // 100th oldest
|
||||
});
|
||||
|
||||
it("filters by guild", () => {
|
||||
const rows = [mk("x", 500, "g1"), mk("y", 400, "g2")];
|
||||
const { data } = streamAll(rows, { guildId: "g2" });
|
||||
expect(data.map((r) => r.id)).toEqual(["y"]);
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,7 @@ handles a whole batch (text + media split internally, parallel paths).
|
||||
- `embeddingClient.ts` + `qdrantClient.ts` — semantic cache (one embed call +
|
||||
one batched Qdrant search for all uncached targets).
|
||||
- `textCacheStore.ts` / `channelCultureStore.ts` / `userProfileStore.ts` /
|
||||
`userReputationStore.ts` — caches & learned per-channel/user state.
|
||||
`userProfileStore.ts` — caches learned user profile summaries (optional).
|
||||
|
||||
### Concurrency model
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ Orchestration/caching: `moderationOrchestrator.ts` (exact hash → batched
|
||||
semantic Qdrant → LLM), `textBatchProcessor.ts` / `mediaBatchProcessor.ts`
|
||||
(one LLM call per sub-batch), `llmClient.ts` (central streaming client),
|
||||
`embeddingClient.ts` + `qdrantClient.ts` (semantic cache), plus
|
||||
`channelCultureStore.ts` / `userProfileStore.ts` / `userReputationStore.ts`.
|
||||
`channelCultureStore.ts` / `userProfileStore.ts`.
|
||||
|
||||
### voice-recording
|
||||
`voiceController.ts` (connect/disconnect/list) + `recorder.ts` (orchestration)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Migration: 0015_add_moderation_explainability.sql
|
||||
-- Date: 2026-08-18
|
||||
-- Description: Add structured explainability columns to moderation_actions.
|
||||
-- These are READ (surfaced read-only to the public web) so they are never
|
||||
-- "written but never read" — they back the public moderation transparency view.
|
||||
-- Idempotent: no-ops on databases that already carry the columns.
|
||||
|
||||
ALTER TABLE IF EXISTS "moderation_actions"
|
||||
ADD COLUMN IF NOT EXISTS "flags" text,
|
||||
ADD COLUMN IF NOT EXISTS "categories" text,
|
||||
ADD COLUMN IF NOT EXISTS "severity" text
|
||||
CHECK ("severity" IS NULL OR "severity" IN ('none','low','medium','high','critical')),
|
||||
ADD COLUMN IF NOT EXISTS "confidence" real,
|
||||
ADD COLUMN IF NOT EXISTS "score" real,
|
||||
ADD COLUMN IF NOT EXISTS "evidence" text,
|
||||
ADD COLUMN IF NOT EXISTS "policy_version" text;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Remove the user reputation feature entirely (trust scores, infractions).
|
||||
-- The feature was removed from the codebase; this drops the orphaned table.
|
||||
DROP TABLE IF EXISTS "user_reputations";
|
||||
@@ -106,6 +106,20 @@
|
||||
"when": 1785621600000,
|
||||
"tag": "0014_add_term_glossary_cache",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1787184000000,
|
||||
"tag": "0015_add_moderation_explainability",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1787185000000,
|
||||
"tag": "0016_drop_user_reputations",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+36
-200
@@ -29,9 +29,6 @@ importers:
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.2
|
||||
version: 0.45.2(@types/pg@8.20.0)(pg@8.22.0)
|
||||
imghash:
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4
|
||||
ioredis:
|
||||
specifier: ^5.11.0
|
||||
version: 5.11.1(supports-color@7.2.0)
|
||||
@@ -80,7 +77,7 @@ importers:
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: latest
|
||||
version: 2.5.7
|
||||
version: 2.5.8
|
||||
'@types/node':
|
||||
specifier: ^25.9.0
|
||||
version: 25.9.5
|
||||
@@ -105,86 +102,63 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@biomejs/biome@2.5.7':
|
||||
resolution: {integrity: sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==}
|
||||
'@biomejs/biome@2.5.8':
|
||||
resolution: {integrity: sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.5.7':
|
||||
resolution: {integrity: sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==}
|
||||
'@biomejs/cli-darwin-arm64@2.5.8':
|
||||
resolution: {integrity: sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.5.7':
|
||||
resolution: {integrity: sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==}
|
||||
'@biomejs/cli-darwin-x64@2.5.8':
|
||||
resolution: {integrity: sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.5.7':
|
||||
resolution: {integrity: sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==}
|
||||
'@biomejs/cli-linux-arm64-musl@2.5.8':
|
||||
resolution: {integrity: sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.5.7':
|
||||
resolution: {integrity: sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==}
|
||||
'@biomejs/cli-linux-arm64@2.5.8':
|
||||
resolution: {integrity: sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.5.7':
|
||||
resolution: {integrity: sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==}
|
||||
'@biomejs/cli-linux-x64-musl@2.5.8':
|
||||
resolution: {integrity: sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.5.7':
|
||||
resolution: {integrity: sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==}
|
||||
'@biomejs/cli-linux-x64@2.5.8':
|
||||
resolution: {integrity: sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.5.7':
|
||||
resolution: {integrity: sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==}
|
||||
'@biomejs/cli-win32-arm64@2.5.8':
|
||||
resolution: {integrity: sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.5.7':
|
||||
resolution: {integrity: sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==}
|
||||
'@biomejs/cli-win32-x64@2.5.8':
|
||||
resolution: {integrity: sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@canvas/image-data@1.1.0':
|
||||
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
|
||||
|
||||
'@canvas/image@2.0.0':
|
||||
resolution: {integrity: sha512-DQKEftZ5M4eM8Rzhv8FFZGdOsO8bVjzPC/9pBF+P//HjQM/Xf+PvQRTv97mVpsHt+Pejs1V7ooK6xf+dYgDNpw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@cwasm/jpeg-turbo@0.1.3':
|
||||
resolution: {integrity: sha512-FkZxwwC6r4zhzlqM0nYGaMj/MDSrZPxLOdPdM6ySlgsMfOpNAZcLQkpNF4jP+DmsuUvRoeUD0YSMBvg3jYfK6w==}
|
||||
|
||||
'@cwasm/lodepng@0.1.9':
|
||||
resolution: {integrity: sha512-vb2H7/jTxnqJi7hHiEgtFm3smIqVpeY417vN+8cwsq3iTNrHzwnMzFXbeCf2H9Dl13Vh64qgUUcC2mxf6TPODA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@cwasm/nsbmp@0.1.3':
|
||||
resolution: {integrity: sha512-APiz9Rj2E049rBapTtwnCGqQeqJjmC85busDQ44UCaujU+LoggUm06NuS1WIBAZcDMRbJOgCFcRWc0tMT2kpfg==}
|
||||
|
||||
'@cwasm/nsgif@0.1.2':
|
||||
resolution: {integrity: sha512-LOD5HlL0O5jpnIAl+dLSZcB3v0RBNBjtoaymdCEPe2kyKzaP20BF+jy/QUyOZogQsgMVjusZES3tgwwoiiJ2rA==}
|
||||
|
||||
'@cwasm/webp@0.1.5':
|
||||
resolution: {integrity: sha512-ceIZQkyxK+s7mmItNcWqqHdOBiJAxYxTnrnPNgUNjldB1M9j+Bp/3eVIVwC8rUFyN/zoFwuT0331pyY3ackaNA==}
|
||||
|
||||
'@discordjs/builders@1.14.1':
|
||||
resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
@@ -1288,9 +1262,6 @@ packages:
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
blockhash-core@0.1.0:
|
||||
resolution: {integrity: sha512-Cv7BgBo0jjVPaeuel4cvxf9LqIGsYNIPz9DAGvvrF9LRlEq9Q3HXu+S8bklPCae0sCxAXic4HGMoImf3FeO3Nw==}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
||||
|
||||
@@ -1377,18 +1348,6 @@ packages:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decode-bmp@0.2.1:
|
||||
resolution: {integrity: sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
decode-ico@0.4.1:
|
||||
resolution: {integrity: sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -1566,15 +1525,6 @@ packages:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-base64-decode@1.0.0:
|
||||
resolution: {integrity: sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==}
|
||||
|
||||
fast-base64-encode@1.0.0:
|
||||
resolution: {integrity: sha512-z2XCzVK4fde2cuTEHu2QGkLD6BPtJNKJPn0Z7oINvmhq/quUuIIVPYKUdN0gYeZqOyurjJjBH/bUzK5gafyHvw==}
|
||||
|
||||
fast-base64-length@1.0.0:
|
||||
resolution: {integrity: sha512-MV+/ioblHx6SMjc/1l4EAnRJyAku6+6DxZ6RW0FoFCF1Aol/Ldb6FqwE3Kn3Ju1aam2m1KCIVoCljhgcG+Umzg==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -1590,10 +1540,6 @@ packages:
|
||||
fetch-cookie@3.2.0:
|
||||
resolution: {integrity: sha512-n61pQIxP25C6DRhcJxn7BDzgHP/+S56Urowb5WFxtcRMpU6drqXD90xjyAsVQYsNSNNVbaCcYY1DuHsdkZLuiA==}
|
||||
|
||||
file-type@10.11.0:
|
||||
resolution: {integrity: sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
find-process@2.1.1:
|
||||
resolution: {integrity: sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA==}
|
||||
hasBin: true
|
||||
@@ -1684,14 +1630,6 @@ packages:
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
image-type@4.1.0:
|
||||
resolution: {integrity: sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
imghash@1.1.4:
|
||||
resolution: {integrity: sha512-atRI6S7rwrdZlr8pBsuk0NfBfeuPzyna98ugz2Sodj1P8SOZJLVYuqWnUrUFg2YnFTxtdbdSq4Ucn1osLFQ+qA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
inflight@1.0.6:
|
||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
@@ -1711,9 +1649,6 @@ packages:
|
||||
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
jpeg-js@0.4.4:
|
||||
resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
|
||||
|
||||
libsodium-wrappers@0.8.4:
|
||||
resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==}
|
||||
|
||||
@@ -1831,10 +1766,6 @@ packages:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mimic-response@3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
@@ -2139,12 +2070,6 @@ packages:
|
||||
signal-exit@3.0.7:
|
||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
sonic-boom@4.2.1:
|
||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||
|
||||
@@ -2231,9 +2156,6 @@ packages:
|
||||
resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==}
|
||||
hasBin: true
|
||||
|
||||
to-data-view@1.1.0:
|
||||
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
|
||||
|
||||
tough-cookie@5.1.2:
|
||||
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -2426,77 +2348,41 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@biomejs/biome@2.5.7':
|
||||
'@biomejs/biome@2.5.8':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.5.7
|
||||
'@biomejs/cli-darwin-x64': 2.5.7
|
||||
'@biomejs/cli-linux-arm64': 2.5.7
|
||||
'@biomejs/cli-linux-arm64-musl': 2.5.7
|
||||
'@biomejs/cli-linux-x64': 2.5.7
|
||||
'@biomejs/cli-linux-x64-musl': 2.5.7
|
||||
'@biomejs/cli-win32-arm64': 2.5.7
|
||||
'@biomejs/cli-win32-x64': 2.5.7
|
||||
'@biomejs/cli-darwin-arm64': 2.5.8
|
||||
'@biomejs/cli-darwin-x64': 2.5.8
|
||||
'@biomejs/cli-linux-arm64': 2.5.8
|
||||
'@biomejs/cli-linux-arm64-musl': 2.5.8
|
||||
'@biomejs/cli-linux-x64': 2.5.8
|
||||
'@biomejs/cli-linux-x64-musl': 2.5.8
|
||||
'@biomejs/cli-win32-arm64': 2.5.8
|
||||
'@biomejs/cli-win32-x64': 2.5.8
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.5.7':
|
||||
'@biomejs/cli-darwin-arm64@2.5.8':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.5.7':
|
||||
'@biomejs/cli-darwin-x64@2.5.8':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.5.7':
|
||||
'@biomejs/cli-linux-arm64-musl@2.5.8':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.5.7':
|
||||
'@biomejs/cli-linux-arm64@2.5.8':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.5.7':
|
||||
'@biomejs/cli-linux-x64-musl@2.5.8':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64@2.5.7':
|
||||
'@biomejs/cli-linux-x64@2.5.8':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.5.7':
|
||||
'@biomejs/cli-win32-arm64@2.5.8':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-x64@2.5.7':
|
||||
'@biomejs/cli-win32-x64@2.5.8':
|
||||
optional: true
|
||||
|
||||
'@canvas/image-data@1.1.0': {}
|
||||
|
||||
'@canvas/image@2.0.0':
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
'@cwasm/jpeg-turbo': 0.1.3
|
||||
'@cwasm/lodepng': 0.1.9
|
||||
'@cwasm/nsbmp': 0.1.3
|
||||
'@cwasm/nsgif': 0.1.2
|
||||
'@cwasm/webp': 0.1.5
|
||||
decode-ico: 0.4.1
|
||||
fast-base64-decode: 1.0.0
|
||||
fast-base64-encode: 1.0.0
|
||||
fast-base64-length: 1.0.0
|
||||
simple-get: 4.0.1
|
||||
|
||||
'@cwasm/jpeg-turbo@0.1.3':
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
|
||||
'@cwasm/lodepng@0.1.9':
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
|
||||
'@cwasm/nsbmp@0.1.3':
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
|
||||
'@cwasm/nsgif@0.1.2':
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
|
||||
'@cwasm/webp@0.1.5':
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
|
||||
'@discordjs/builders@1.14.1':
|
||||
dependencies:
|
||||
'@discordjs/formatters': 0.6.2
|
||||
@@ -3280,8 +3166,6 @@ snapshots:
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
blockhash-core@0.1.0: {}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
@@ -3352,21 +3236,6 @@ snapshots:
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decode-bmp@0.2.1:
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
to-data-view: 1.1.0
|
||||
|
||||
decode-ico@0.4.1:
|
||||
dependencies:
|
||||
'@canvas/image-data': 1.1.0
|
||||
decode-bmp: 0.2.1
|
||||
to-data-view: 1.1.0
|
||||
|
||||
decompress-response@6.0.0:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
delegates@1.0.0: {}
|
||||
@@ -3535,12 +3404,6 @@ snapshots:
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
fast-base64-decode@1.0.0: {}
|
||||
|
||||
fast-base64-encode@1.0.0: {}
|
||||
|
||||
fast-base64-length@1.0.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.5):
|
||||
@@ -3552,8 +3415,6 @@ snapshots:
|
||||
set-cookie-parser: 2.7.2
|
||||
tough-cookie: 6.0.2
|
||||
|
||||
file-type@10.11.0: {}
|
||||
|
||||
find-process@2.1.1:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
@@ -3658,17 +3519,6 @@ snapshots:
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
image-type@4.1.0:
|
||||
dependencies:
|
||||
file-type: 10.11.0
|
||||
|
||||
imghash@1.1.4:
|
||||
dependencies:
|
||||
'@canvas/image': 2.0.0
|
||||
blockhash-core: 0.1.0
|
||||
image-type: 4.1.0
|
||||
jpeg-js: 0.4.4
|
||||
|
||||
inflight@1.0.6:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
@@ -3692,8 +3542,6 @@ snapshots:
|
||||
|
||||
is-network-error@1.3.2: {}
|
||||
|
||||
jpeg-js@0.4.4: {}
|
||||
|
||||
libsodium-wrappers@0.8.4:
|
||||
dependencies:
|
||||
libsodium: 0.8.4
|
||||
@@ -3780,8 +3628,6 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.18
|
||||
@@ -4061,14 +3907,6 @@ snapshots:
|
||||
|
||||
signal-exit@3.0.7: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
simple-get@4.0.1:
|
||||
dependencies:
|
||||
decompress-response: 6.0.0
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
sonic-boom@4.2.1:
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
@@ -4148,8 +3986,6 @@ snapshots:
|
||||
dependencies:
|
||||
tldts-core: 7.4.9
|
||||
|
||||
to-data-view@1.1.0: {}
|
||||
|
||||
tough-cookie@5.1.2:
|
||||
dependencies:
|
||||
tldts: 6.1.86
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Rewrite import specifiers in the compiled dist/ so the output runs under
|
||||
// plain `node dist/index.js` (native ESM, no bundler / no tsx).
|
||||
//
|
||||
// Background: tsconfig uses moduleResolution:"bundler", so `tsc` emits BARE
|
||||
// relative specifiers WITHOUT extensions (e.g. `import "./router"`) and leaves
|
||||
// the `@/*` path-alias imports untouched. Node's native ESM resolver rejects
|
||||
// extensionless relative specifiers and knows nothing about the `@/` alias, so
|
||||
// the emitted dist/ crashes at startup (`ERR_MODULE_NOT_FOUND`). This script
|
||||
// fixes both:
|
||||
// 1. `@/foo` -> relative path to dist/foo.js
|
||||
// 2. `./foo` / `../foo` -> `./foo.js` / `../foo.js` (append .js)
|
||||
// Already-extensioned relative imports (.js/.json/.node/.mjs/.cjs) and bare
|
||||
// package specifiers are left untouched (idempotent).
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { join, relative, dirname } from "node:path";
|
||||
|
||||
let count = 0;
|
||||
function walk(dir) {
|
||||
if (!existsSync(dir)) return;
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (e.name.endsWith(".js")) {
|
||||
const c = readFileSync(p, "utf8");
|
||||
const pat = /from\s+['"]([^'"]+)['"]/g;
|
||||
const n = c.replace(pat, (m, spec) => {
|
||||
if (spec.startsWith("@/")) {
|
||||
// Source may already carry an extension (e.g. "@/shared/config/index.js");
|
||||
// only append ".js" when the specifier has none — otherwise we'd
|
||||
// produce "index.js.js".
|
||||
const core = spec.slice(2);
|
||||
let target;
|
||||
if (/\.(js|json|node|mjs|cjs)$/.test(core)) {
|
||||
target = join("dist", core);
|
||||
} else {
|
||||
target = join("dist", core) + ".js";
|
||||
}
|
||||
let rel = relative(dirname(p), target);
|
||||
if (!rel.startsWith(".")) rel = "./" + rel;
|
||||
return `from "${rel}"`;
|
||||
}
|
||||
if (
|
||||
(spec.startsWith("./") || spec.startsWith("../")) &&
|
||||
!/\.(js|json|node|mjs|cjs)$/.test(spec)
|
||||
) {
|
||||
return `from "${spec}.js"`;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
if (n !== c) {
|
||||
writeFileSync(p, n);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("dist");
|
||||
console.log(`Fixed ${count} import specifiers in dist/`);
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
registerMessageCapture,
|
||||
setEventBroadcaster as setMessageCaptureEventBroadcaster,
|
||||
} from "../modules/message-capture/messageCapture.js";
|
||||
import { setModerationEventBroadcaster } from "../modules/message-capture/moderationActionsDb.js";
|
||||
import { startDigestScheduler } from "../modules/monitor/digestScheduler.js";
|
||||
import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
|
||||
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
||||
import { registerPresenceCapture } from "../modules/user-presence/index.js";
|
||||
@@ -254,6 +256,7 @@ export async function initializeDiscordGateway() {
|
||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||
setMessageCaptureEventBroadcaster(eventBroadcaster);
|
||||
setRecorderEventBroadcaster(eventBroadcaster);
|
||||
setModerationEventBroadcaster(eventBroadcaster);
|
||||
registerMessageCapture(client);
|
||||
startPendingAIAnalysisWorker(client, eventBroadcaster);
|
||||
|
||||
@@ -273,6 +276,8 @@ export async function initializeDiscordGateway() {
|
||||
|
||||
// Start retention cleanup scheduler
|
||||
startRetentionCleanup();
|
||||
// Start weekly moderation digest (public, automated)
|
||||
startDigestScheduler();
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
|
||||
@@ -93,6 +93,12 @@ type BatchOkResponse = {
|
||||
ok: true;
|
||||
conversationKey: string;
|
||||
rows: MessageRecord[];
|
||||
/**
|
||||
* Race-guard signal (2026-08-25): target ids whose attachment upload is
|
||||
* still in-flight — NO analysis ran for them. The processor must defer
|
||||
* these (requeue + poll), never fan them out as failures.
|
||||
*/
|
||||
uploadPendingIds?: string[];
|
||||
};
|
||||
type BatchErrorResponse = {
|
||||
ok: false;
|
||||
@@ -100,7 +106,16 @@ type BatchErrorResponse = {
|
||||
rows: MessageRecord[];
|
||||
error: string;
|
||||
};
|
||||
type IndividualOkResponse = { ok: true; results: AnalysisResult[] };
|
||||
type IndividualOkResponse = {
|
||||
ok: true;
|
||||
results: AnalysisResult[];
|
||||
/**
|
||||
* Race-guard signal (2026-08-24): the message's attachment upload is still
|
||||
* in-flight — NO analysis ran. The processor must re-queue the message as
|
||||
* `pending` and re-schedule, never treat this as a completed moderation.
|
||||
*/
|
||||
uploadPending?: boolean;
|
||||
};
|
||||
type IndividualErrorResponse = {
|
||||
ok: false;
|
||||
results: AnalysisResult[];
|
||||
@@ -310,7 +325,16 @@ async function processBatch(job: {
|
||||
? messages
|
||||
: messages.filter((m) => !pendingUploadTargetIds.has(m.id));
|
||||
if (readyMessages.length === 0) {
|
||||
return { ok: true, conversationKey, rows: [] };
|
||||
// Explicit signal (2026-08-25): every target is still upload-pending.
|
||||
// Returning bare {ok:true, rows:[]} made the processor classify all of
|
||||
// them "incomplete" and fan out to the individual queue — a hot ~300ms
|
||||
// requeue loop for the whole upload duration.
|
||||
return {
|
||||
ok: true,
|
||||
conversationKey,
|
||||
rows: [],
|
||||
uploadPendingIds: messages.map((m) => m.id),
|
||||
};
|
||||
}
|
||||
|
||||
// The orchestrator handles text/media split + caching + parallel paths
|
||||
@@ -415,7 +439,7 @@ async function processIndividual(job: {
|
||||
(a) => a.message_id === message.id && a.upload_status === "pending",
|
||||
);
|
||||
if (uploadStillPending) {
|
||||
return { ok: true, results: [] };
|
||||
return { ok: true, results: [], uploadPending: true };
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Loaded 50 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user