Compare commits
154
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 | ||
|
|
2fa1827f17 | ||
|
|
d8552a9fb8 | ||
|
|
a4abe3abea | ||
|
|
b67856462f | ||
|
|
30828a5534 | ||
|
|
a3e5a8c1b9 | ||
|
|
f82b5caae4 | ||
|
|
9e2b107fcd | ||
|
|
d2e97ae11d | ||
|
|
6244e307a3 | ||
|
|
2d7c7f2c35 | ||
|
|
416c690ebc | ||
|
|
c590a8be27 | ||
|
|
9c83ec86cc | ||
|
|
17a4fbd73d | ||
|
|
c04c410fad | ||
|
|
5e5f4ae208 | ||
|
|
e2013988ff | ||
|
|
0164444dd7 | ||
|
|
9ae26b8ec9 | ||
|
|
7ebee7559d | ||
|
|
66c33a2657 | ||
|
|
25b220b7f9 | ||
|
|
1c4f28c5f2 | ||
|
|
392db8eba1 | ||
|
|
3c2c1c3b15 | ||
|
|
1b56212d1a | ||
|
|
b98101c576 | ||
|
|
84757bdcf4 | ||
|
|
6c9a91dad4 | ||
|
|
bcb563ea7f | ||
|
|
589fd38fd8 | ||
|
|
da02bfff9b | ||
|
|
a66db8d702 | ||
|
|
d65dc11c73 | ||
|
|
8b281c7feb | ||
|
|
5bbf75a65b | ||
|
|
5816e94a63 | ||
|
|
11f2ad5f23 | ||
|
|
6e188f81d6 | ||
|
|
7c376ea66a | ||
|
|
8ee32b8df8 | ||
|
|
c285a4c813 | ||
|
|
f156fc0c9e | ||
|
|
89f1097729 | ||
|
|
df24c756a0 | ||
|
|
add31d3561 | ||
|
|
6e7c4901c9 | ||
|
|
60faaa9304 | ||
|
|
5505983dbd | ||
|
|
3d57e9c102 | ||
|
|
d3cb5f6756 | ||
|
|
37787cc4f0 | ||
|
|
f849a87f2f | ||
|
|
deb5dedf2c | ||
|
|
3b221823e7 | ||
|
|
9f7ce7dbd5 | ||
|
|
bd292fdf3d | ||
|
|
7a7f433988 | ||
|
|
84c5c36672 | ||
|
|
c5898f7cf0 | ||
|
|
354e378e74 | ||
|
|
392bc35a0d | ||
|
|
196cb1d3af | ||
|
|
00fc852a32 | ||
|
|
d9f5592e6e | ||
|
|
f1aa08cdf6 | ||
|
|
f70a92880e | ||
|
|
88b13225cd | ||
|
|
67ab289caa | ||
|
|
ef9e243609 | ||
|
|
42a503c206 | ||
|
|
652974e23a | ||
|
|
407e003399 | ||
|
|
968a43b0f4 | ||
|
|
91c7a67d2f | ||
|
|
8615383829 | ||
|
|
10d7ecd405 | ||
|
|
ff554fcff2 | ||
|
|
f8b253ba5e |
+3
-3
@@ -84,9 +84,9 @@ BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (d
|
|||||||
|
|
||||||
# === AI Analysis ===
|
# === AI Analysis ===
|
||||||
AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
|
AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
|
||||||
# AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
|
AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
|
||||||
AI_LLM_BASE_URL=http://100.121.180.82:20128/api/v1 # LLM API base URL (omniroute on imrnes; /api/v1 exposes OpenAI-compatible chat+embeddings)
|
AI_LLM_BASE_URL=https://9router.asepharyana.my.id/v1 # LLM API base URL (9router — OpenAI-compatible router, replaces omniroute)
|
||||||
AI_LLM_MODEL=text # LLM text model name (default: text)
|
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_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL)
|
||||||
# AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
|
# AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
|
||||||
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
|
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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)".
|
||||||
File diff suppressed because one or more lines are too long
@@ -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.
|
||||||
+9
-3
@@ -35,12 +35,15 @@
|
|||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noUnknownAtRules": "off",
|
"noUnknownAtRules": "off",
|
||||||
"useIterableCallbackReturn": "off",
|
"useIterableCallbackReturn": "off",
|
||||||
"noArrayIndexKey": "warn"
|
"noArrayIndexKey": "off",
|
||||||
|
"noExplicitAny": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"useSemanticElements": "off",
|
"useSemanticElements": "off",
|
||||||
"useButtonType": "off",
|
"useButtonType": "off",
|
||||||
"noAutofocus": "off"
|
"noAutofocus": "off",
|
||||||
|
"useMediaCaption": "off",
|
||||||
|
"noStaticElementInteractions": "off"
|
||||||
},
|
},
|
||||||
"performance": {
|
"performance": {
|
||||||
"noImgElement": "warn"
|
"noImgElement": "warn"
|
||||||
@@ -50,7 +53,10 @@
|
|||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"noInvalidUseBeforeDeclaration": "off",
|
"noInvalidUseBeforeDeclaration": "off",
|
||||||
"noUnusedFunctionParameters": "warn"
|
"noUnusedFunctionParameters": "warn",
|
||||||
|
"noUnusedVariables": "warn",
|
||||||
|
"noUnusedImports": "warn",
|
||||||
|
"noUnusedPrivateClassMembers": "warn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domains": {
|
"domains": {
|
||||||
|
|||||||
@@ -11,11 +11,6 @@
|
|||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
# libdatachannel for the GoLive N-API binding. nixpkgs 0.24.1 is built
|
|
||||||
# against this host's glibc and ships both lib + dev headers, so the
|
|
||||||
# binding links cleanly inside the Nix sandbox (no manual cmake build).
|
|
||||||
libdatachannel = pkgs.libdatachannel;
|
|
||||||
|
|
||||||
# Source filter: `path:` literals do NOT respect .gitignore by default,
|
# Source filter: `path:` literals do NOT respect .gitignore by default,
|
||||||
# so a dirty local out/ (stale chunks from previous builds) leaks into
|
# so a dirty local out/ (stale chunks from previous builds) leaks into
|
||||||
# the sandbox. Filter out build artifacts explicitly.
|
# the sandbox. Filter out build artifacts explicitly.
|
||||||
@@ -53,8 +48,8 @@
|
|||||||
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
||||||
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
||||||
|
|
||||||
# pnpm uses node-gyp for native addons — provide build tools
|
# pnpm uses node-gyp for native addons — provide build tools (kept for
|
||||||
export npm_config_build_from_source=true
|
# the rare case a prebuilt is unavailable and it falls back to compile).
|
||||||
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
|
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
|
||||||
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
|
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
|
||||||
|
|
||||||
@@ -73,7 +68,7 @@
|
|||||||
# NOTE: do NOT use `pnpm install --prod` here — it collapses the
|
# NOTE: do NOT use `pnpm install --prod` here — it collapses the
|
||||||
# public-hoist dir (.pnpm/node_modules) that runtime peer resolution
|
# public-hoist dir (.pnpm/node_modules) that runtime peer resolution
|
||||||
# relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64
|
# relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64
|
||||||
# are only reachable through it), silently breaking voice/screenshare.
|
# are only reachable through it), silently breaking voice.
|
||||||
# Instead we keep the full install's symlink layout and only prune
|
# Instead we keep the full install's symlink layout and only prune
|
||||||
# orphaned package dirs + broken symlinks.
|
# orphaned package dirs + broken symlinks.
|
||||||
# Must run AFTER tsc (typescript is a devDep) and after native builds.
|
# Must run AFTER tsc (typescript is a devDep) and after native builds.
|
||||||
@@ -106,31 +101,8 @@
|
|||||||
buildPhase = pnpmInstall + ''
|
buildPhase = pnpmInstall + ''
|
||||||
echo "=== Compiling TypeScript ==="
|
echo "=== Compiling TypeScript ==="
|
||||||
npx tsc 2>&1
|
npx tsc 2>&1
|
||||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||||
node -e "
|
node scripts/fix-imports.mjs
|
||||||
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 "=== Build complete ==="
|
echo "=== Build complete ==="
|
||||||
'' + pruneProd;
|
'' + pruneProd;
|
||||||
|
|
||||||
@@ -167,8 +139,7 @@ WRAPPER
|
|||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
pkgs.openssl.dev
|
pkgs.openssl.dev
|
||||||
libdatachannel.dev # rtc/rtc.hpp headers for the GoLive binding
|
pkgs.git # for any FetchContent-based deps during native builds
|
||||||
pkgs.git # libdatachannel FetchContent clones from GitHub
|
|
||||||
pkgs.cacert
|
pkgs.cacert
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -181,65 +152,35 @@ WRAPPER
|
|||||||
# do NOT let stdenv run its own cmake configure phase on the source.
|
# do NOT let stdenv run its own cmake configure phase on the source.
|
||||||
dontUseCmakeConfigure = true;
|
dontUseCmakeConfigure = true;
|
||||||
|
|
||||||
|
# The gateway bundles native node_modules (.node addons plus .o/.a
|
||||||
|
# object files left in prebuilt dirs). stdenv's fixupPhase walks
|
||||||
|
# $out/node_modules and runs patchELF + shrinkELF over every ELF it
|
||||||
|
# finds, choking on the non-ET_DYN files (.o/.a) and the prebuilt
|
||||||
|
# .node addons — emitting hundreds of harmless "patchelf: wrong ELF
|
||||||
|
# type" lines per build. The real binary is node (external, already
|
||||||
|
# RPATH-fixed in its own derivation) and the .node addons are
|
||||||
|
# self-contained prebuilts loaded via dlopen, so Nix's fixup pass is
|
||||||
|
# neither needed nor wanted here. Skip it entirely.
|
||||||
|
dontFixup = true;
|
||||||
|
|
||||||
buildPhase = pnpmInstall + ''
|
buildPhase = pnpmInstall + ''
|
||||||
echo "=== Building native voice deps ==="
|
echo "=== Building native voice deps ==="
|
||||||
# pnpm rebuild aborts on the first failing package and runs scripts
|
# pnpm rebuild aborts on the first failing package and runs scripts
|
||||||
# from the wrong cwd — build each native dep explicitly with its own
|
# from the wrong cwd — build each native dep explicitly with its own
|
||||||
# install script. Each failure is tolerated (|| true); the packages
|
# install script. Each failure is tolerated (|| true); the packages
|
||||||
# that matter (opus) are verified at runtime.
|
# @discordjs/opus ships prebuilt binaries for Node 22 (ABI node-v127,
|
||||||
for pkg in \
|
# linux-x64-glibc-2.35) — node-pre-gyp downloads the prebuilt .node
|
||||||
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus
|
# instead of compiling C++ from source. With build_from_source unset
|
||||||
do
|
# (above), `pnpm rebuild` runs the package's own install script which
|
||||||
if [ -d "$pkg" ]; then
|
# fetches the matching prebuilt; it only falls back to a source build
|
||||||
echo "--- native build: $pkg ---"
|
# if the download fails. This keeps voice working without a per-build
|
||||||
(cd "$pkg" && npm run install 2>&1 || true)
|
# native compile.
|
||||||
fi
|
echo "=== Rebuilding @discordjs/opus (prebuilt download) ==="
|
||||||
done
|
pnpm rebuild @discordjs/opus 2>&1 || true
|
||||||
echo "=== Building libdatachannel-min N-API binding ==="
|
|
||||||
# The GoLive screen-share stack uses a minimal N-API binding
|
|
||||||
# (native/libdatachannel-min) over nixpkgs libdatachannel.
|
|
||||||
(
|
|
||||||
cd native/libdatachannel-min
|
|
||||||
# binding.gyp resolves include/lib from env (LDC_INCLUDE = .dev
|
|
||||||
# include root, LDC_LIB = lib output dir, NAPI_INCLUDE =
|
|
||||||
# node-addon-api include root).
|
|
||||||
NAPI_INCLUDE=$(find ../../node_modules/.pnpm -maxdepth 3 \
|
|
||||||
-type d -path "*node_modules/node-addon-api" | head -1)
|
|
||||||
echo "NAPI_INCLUDE=$NAPI_INCLUDE"
|
|
||||||
LDC_INCLUDE=${libdatachannel.dev} LDC_LIB=${libdatachannel.out}/lib/libdatachannel.so.0.24.1 \
|
|
||||||
NAPI_INCLUDE=$NAPI_INCLUDE \
|
|
||||||
npx node-gyp rebuild 2>&1 || true
|
|
||||||
ls -la build/Release/datachannel_min.node 2>/dev/null \
|
|
||||||
&& echo "libdatachannel-min binding OK: $(stat -c%s build/Release/datachannel_min.node) bytes" \
|
|
||||||
|| echo "WARN: libdatachannel-min binding build FAILED (screen share disabled)"
|
|
||||||
)
|
|
||||||
echo "=== Compiling TypeScript ===="
|
echo "=== Compiling TypeScript ===="
|
||||||
npx tsc 2>&1
|
npx tsc 2>&1
|
||||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||||
node -e "
|
node scripts/fix-imports.mjs
|
||||||
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 "=== Build complete ==="
|
echo "=== Build complete ==="
|
||||||
'' + pruneProd;
|
'' + pruneProd;
|
||||||
|
|
||||||
@@ -247,22 +188,6 @@ WRAPPER
|
|||||||
mkdir -p $out/lib/gmw-discord-gateway
|
mkdir -p $out/lib/gmw-discord-gateway
|
||||||
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/
|
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/
|
||||||
|
|
||||||
# GoLive native binding — loadNative resolves it relative to
|
|
||||||
# dist/goLive/native.js, i.e. <root>/native/libdatachannel-min/
|
|
||||||
# build/Release/datachannel_min.node; libdatachannel .so must sit
|
|
||||||
# next to it and be on LD_LIBRARY_PATH at runtime.
|
|
||||||
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release
|
|
||||||
cp native/libdatachannel-min/build/Release/datachannel_min.node \
|
|
||||||
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/ 2>/dev/null || true
|
|
||||||
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc
|
|
||||||
cp -rL native/libdatachannel-min/build/ldc/libdatachannel.so* \
|
|
||||||
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc/ 2>/dev/null || true
|
|
||||||
# If the binding failed to build, screen share is simply disabled —
|
|
||||||
# the gateway itself must still start.
|
|
||||||
if [ ! -f $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/datachannel_min.node ]; then
|
|
||||||
echo "WARN: datachannel_min.node missing — GoLive screen share disabled in this build"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Also include drizzle migrations if they exist
|
# Also include drizzle migrations if they exist
|
||||||
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
|
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
|
||||||
|
|
||||||
@@ -271,7 +196,6 @@ WRAPPER
|
|||||||
#!${pkgs.runtimeShell}
|
#!${pkgs.runtimeShell}
|
||||||
cd $out/lib/gmw-discord-gateway
|
cd $out/lib/gmw-discord-gateway
|
||||||
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
|
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
|
||||||
export LD_LIBRARY_PATH=${libdatachannel.out}/lib:\$LD_LIBRARY_PATH
|
|
||||||
exec ${nodejs}/bin/node dist/index.js
|
exec ${nodejs}/bin/node dist/index.js
|
||||||
WRAPPER
|
WRAPPER
|
||||||
chmod +x $out/bin/gmw-discord-gateway
|
chmod +x $out/bin/gmw-discord-gateway
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ http {
|
|||||||
proxy_send_timeout 86400s;
|
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 ───────────
|
# ── Next.js build assets — immutable, edge/shareable ───────────
|
||||||
location ^~ /_next/static/ {
|
location ^~ /_next/static/ {
|
||||||
proxy_pass http://gmw_next$uri$is_args$args;
|
proxy_pass http://gmw_next$uri$is_args$args;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Migration: add ai_analysis_duration_ms to messages
|
||||||
|
-- Tracks how long the AI moderation LLM call took, per message (ms).
|
||||||
|
-- Idempotent: safe to re-run.
|
||||||
|
--
|
||||||
|
-- Run against the production GMW database, e.g.:
|
||||||
|
-- PGPASSWORD=*** psql -h 100.121.180.82 -p 6432 -U asephs -d dcbot \
|
||||||
|
-- -f scripts/add-ai-analysis-duration.sql
|
||||||
|
|
||||||
|
ALTER TABLE "messages"
|
||||||
|
ADD COLUMN IF NOT EXISTS "ai_analysis_duration_ms" BIGINT;
|
||||||
@@ -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,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
|
"@orpc/server": "1.15.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
@@ -31,10 +32,10 @@
|
|||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.9.0",
|
"@types/node": "^25.9.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.22.2",
|
"tsx": "^4.22.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"vitest": "latest"
|
"vitest": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2735
File diff suppressed because it is too large
Load Diff
@@ -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,3 +1,5 @@
|
|||||||
|
import { onError } from "@orpc/server";
|
||||||
|
import { RPCHandler } from "@orpc/server/node";
|
||||||
import express, {
|
import express, {
|
||||||
type Express,
|
type Express,
|
||||||
type NextFunction,
|
type NextFunction,
|
||||||
@@ -6,20 +8,17 @@ import express, {
|
|||||||
} from "express";
|
} from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { createAnalysisRouter } from "../modules/analysis/index.js";
|
|
||||||
import { createChatbotRouter } from "../modules/chatbot/index.js";
|
|
||||||
import { createConfigRouter } from "../modules/config/index.js";
|
|
||||||
import { createDashboardRouter } from "../modules/dashboard/index.js";
|
|
||||||
import { createHealthRouter } from "../modules/health/index.js";
|
import { createHealthRouter } from "../modules/health/index.js";
|
||||||
import { createMediaRouter } from "../modules/media/index.js";
|
import { appRouter } from "../orpc/router";
|
||||||
import { createMessagesRouter } from "../modules/messages/index.js";
|
|
||||||
import { createModerationRouter } from "../modules/moderation/index.js";
|
|
||||||
import { createRecordingsRouter } from "../modules/recordings/index.js";
|
|
||||||
import { createUiStateRouter } from "../modules/ui-state/index.js";
|
|
||||||
import { createVoiceRouter } from "../modules/voice/index.js";
|
|
||||||
import { errorHandler } from "../shared/middlewares/index.js";
|
import { errorHandler } from "../shared/middlewares/index.js";
|
||||||
|
|
||||||
// Auth removed — dashboard is public
|
// Auth removed — dashboard is public.
|
||||||
|
// All data APIs (dashboard, messages, moderation, media, voice, recordings,
|
||||||
|
// analysis, chatbot, config, ui-state) now flow over oRPC, served on TWO
|
||||||
|
// transports sharing the /trpc path:
|
||||||
|
// - 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.
|
||||||
|
|
||||||
const logger = createChildLogger("http.app");
|
const logger = createChildLogger("http.app");
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ export function createHttpApp(): Express {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Body parsing
|
// Body parsing (still needed for any JSON POST; oRPC is WS/HTTP-based)
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
@@ -59,24 +58,38 @@ export function createHttpApp(): Express {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// All routes are public
|
// Infra-only HTTP endpoints
|
||||||
app.use("/api", createHealthRouter());
|
app.use("/api", createHealthRouter());
|
||||||
app.use("/api", createConfigRouter());
|
|
||||||
app.use("/api", createDashboardRouter());
|
// oRPC over HTTP (server-side / RSC fetch). The same appRouter the browser
|
||||||
app.use("/api", createMessagesRouter());
|
// reaches over the /trpc WebSocket. oRPC's node RPCHandler writes the full
|
||||||
app.use("/api", createAnalysisRouter());
|
// response itself; if no procedure matched we fall through to the 404 below.
|
||||||
app.use("/api", createChatbotRouter());
|
const orpcHandler = new RPCHandler(appRouter, {
|
||||||
app.use("/api", createRecordingsRouter());
|
interceptors: [onError((error) => logger.error({ error }, "oRPC error"))],
|
||||||
app.use("/api", createUiStateRouter());
|
});
|
||||||
app.use("/api", createMediaRouter());
|
|
||||||
app.use("/api", createVoiceRouter());
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
app.use("/api", createModerationRouter());
|
if (!req.path.startsWith("/trpc")) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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
|
// 404 handler
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req: Request, res: Response) => {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
error: "NOT_FOUND",
|
error: "NOT_FOUND",
|
||||||
message: "Endpoint not found",
|
message:
|
||||||
|
"Endpoint not found — data APIs are served over /trpc (WebSocket/HTTP)",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createServer, type Server } from "node:http";
|
import { createServer, type Server } from "node:http";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { createORPCWebSocketServer } from "../orpc/ws.js";
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import { initializeDatabase } from "../shared/database/index.js";
|
import { initializeDatabase } from "../shared/database/index.js";
|
||||||
import { startRedisBridge } from "../ws/redis-bridge.js";
|
import { startRedisBridge } from "../ws/redis-bridge.js";
|
||||||
@@ -16,8 +17,9 @@ export async function startHttpServer(): Promise<Server> {
|
|||||||
|
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
// Attach WebSocket server to the same HTTP server
|
// Attach WebSocket servers to the same HTTP server
|
||||||
createWebSocketServer(server);
|
createWebSocketServer(server); // /ws — voice PCM + gateway events
|
||||||
|
createORPCWebSocketServer(server); // /trpc — structured data RPCs
|
||||||
|
|
||||||
// Start Redis pub/sub bridge to forward discord-gateway events to WS clients
|
// Start Redis pub/sub bridge to forward discord-gateway events to WS clients
|
||||||
await startRedisBridge();
|
await startRedisBridge();
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { analysisService } from "./analysis.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("analysis.routes");
|
|
||||||
|
|
||||||
export function createAnalysisRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/analysis/search
|
|
||||||
router.get(
|
|
||||||
"/analysis/search",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const q = (req.query.q as string) || "";
|
|
||||||
const channelId = (req.query.channelId as string) || undefined;
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
|
|
||||||
logger.debug({ q, channelId, limit }, "Analysis search requested");
|
|
||||||
const result = await analysisService.search({ q, channelId, limit });
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createAnalysisRouter } from "./analysis.routes.js";
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { chatbotService } from "./chatbot.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("chatbot.controller");
|
|
||||||
|
|
||||||
interface AuthenticatedRequest extends Request {
|
|
||||||
userId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the actor id for a request. Frontend (no-login) sends a per-device
|
|
||||||
* UUID via X-User-Id so chat history stays isolated per visitor; a registered
|
|
||||||
* auth middleware userId takes precedence when present.
|
|
||||||
*/
|
|
||||||
function resolveUserId(req: Request): string {
|
|
||||||
const authId = (req as AuthenticatedRequest).userId;
|
|
||||||
if (authId) return authId;
|
|
||||||
const header = (req.headers["x-user-id"] as string | undefined)?.trim();
|
|
||||||
return header || "anonymous";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleChatbotChat = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const { message, context } = req.body as {
|
|
||||||
message: string;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!message || typeof message !== "string") {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: "INVALID_INPUT",
|
|
||||||
message: "Message is required and must be a string",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user ID from X-User-Id header (no-login device uuid) or auth
|
|
||||||
const userId = resolveUserId(req);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
{ userId, messageLength: message.length, context },
|
|
||||||
"Received chatbot chat message",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process message & generate response
|
|
||||||
const response = await chatbotService.processMessage(
|
|
||||||
message,
|
|
||||||
context,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save conversation to database
|
|
||||||
await chatbotService.saveConversation({
|
|
||||||
userId,
|
|
||||||
userMessage: message,
|
|
||||||
botResponse: response,
|
|
||||||
context,
|
|
||||||
timestamp: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info({ userId }, "Chatbot chat processed successfully");
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
response,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getChatbotHistory = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const userId = resolveUserId(req);
|
|
||||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
|
|
||||||
|
|
||||||
const history = await chatbotService.getChatHistory(userId, limit);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
history,
|
|
||||||
total: history.length,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clearChatbotHistory = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const userId = resolveUserId(req);
|
|
||||||
|
|
||||||
await chatbotService.clearChatHistory(userId);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
message: "Chat history cleared successfully",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -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 { 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";
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("chatbot.repository");
|
const logger = createChildLogger("chatbot.repository");
|
||||||
@@ -31,13 +31,6 @@ export interface ChatbotHistoryRow {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInsights {
|
|
||||||
total_messages: number;
|
|
||||||
active_users: number;
|
|
||||||
flagged: number;
|
|
||||||
warned: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatbotRepository {
|
export class ChatbotRepository {
|
||||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -83,56 +76,6 @@ export class ChatbotRepository {
|
|||||||
"Chat history cleared",
|
"Chat history cleared",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getServerInsights(
|
|
||||||
guildId?: string,
|
|
||||||
channelId?: string,
|
|
||||||
): Promise<ServerInsights> {
|
|
||||||
try {
|
|
||||||
const db = getDatabase();
|
|
||||||
const conditions: SQL[] = [];
|
|
||||||
|
|
||||||
if (guildId) {
|
|
||||||
conditions.push(eq(pgMessagesTable.guild_id, guildId));
|
|
||||||
}
|
|
||||||
if (channelId) {
|
|
||||||
conditions.push(eq(pgMessagesTable.channel_id, channelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
|
|
||||||
const [result] = await db
|
|
||||||
.select({
|
|
||||||
total_messages: sql<number>`COUNT(*)::int`,
|
|
||||||
active_users: sql<number>`COUNT(DISTINCT ${pgMessagesTable.user_id})::int`,
|
|
||||||
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
|
||||||
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
|
||||||
})
|
|
||||||
.from(pgMessagesTable)
|
|
||||||
.where(where);
|
|
||||||
|
|
||||||
const insights = result ?? {
|
|
||||||
total_messages: 0,
|
|
||||||
active_users: 0,
|
|
||||||
flagged: 0,
|
|
||||||
warned: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug({ guildId, channelId, insights }, "Server insights fetched");
|
|
||||||
return insights;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(
|
|
||||||
{ error, guildId, channelId },
|
|
||||||
"Failed to load server insights",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
total_messages: 0,
|
|
||||||
active_users: 0,
|
|
||||||
flagged: 0,
|
|
||||||
warned: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatbotRepository = new ChatbotRepository();
|
export const chatbotRepository = new ChatbotRepository();
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import express, { type Router } from "express";
|
|
||||||
import { validateBody } from "../../shared/middlewares/index.js";
|
|
||||||
import {
|
|
||||||
clearChatbotHistory,
|
|
||||||
getChatbotHistory,
|
|
||||||
handleChatbotChat,
|
|
||||||
} from "./chatbot.controller.js";
|
|
||||||
import { chatRequestSchema } from "./chatbot.schema.js";
|
|
||||||
|
|
||||||
export function createChatbotRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post("/chat", validateBody(chatRequestSchema), handleChatbotChat);
|
|
||||||
router.get("/chat/history", getChatbotHistory);
|
|
||||||
router.delete("/chat/history", clearChatbotHistory);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,8 @@ import type {
|
|||||||
SaveConversationInput,
|
SaveConversationInput,
|
||||||
} from "./chatbot.repository.js";
|
} from "./chatbot.repository.js";
|
||||||
import { chatbotRepository } from "./chatbot.repository.js";
|
import { chatbotRepository } from "./chatbot.repository.js";
|
||||||
import { executeTool, tools } from "./chatbot.tools.js";
|
import { tools } from "./chatbot.toolDefs.js";
|
||||||
|
import { executeTool } from "./chatbot.tools.js";
|
||||||
|
|
||||||
const logger = createChildLogger("chatbot.service");
|
const logger = createChildLogger("chatbot.service");
|
||||||
|
|
||||||
@@ -17,22 +18,26 @@ class ChatbotService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ userId, messageLength: message.length },
|
{ userId, messageLength: message.length, context },
|
||||||
"processMessage called",
|
"processMessage called",
|
||||||
);
|
);
|
||||||
const recentContext = await this.getRecentConversationContext(userId);
|
const recentContext = await this.getRecentConversationContext(userId);
|
||||||
const serverInsights = await chatbotRepository.getServerInsights(
|
// Scope the agent to the server/channel the user is chatting in. We no
|
||||||
context?.guildId,
|
// longer bake server stats into the prompt — the model must pull current
|
||||||
context?.channelId,
|
// data via tools (see buildSystemPrompt), so it always answers from live
|
||||||
);
|
// numbers instead of a stale snapshot.
|
||||||
|
const scope = {
|
||||||
|
guildId: context?.guildId,
|
||||||
|
channelId: context?.channelId,
|
||||||
|
};
|
||||||
|
|
||||||
// Build LLM messages
|
const systemPrompt = this.buildSystemPrompt(scope);
|
||||||
const systemPrompt = this.buildSystemPrompt(serverInsights);
|
|
||||||
const conversationHistory = this.buildHistoryMessages(recentContext);
|
const conversationHistory = this.buildHistoryMessages(recentContext);
|
||||||
const llmResponse = await this.callLLM(
|
const llmResponse = await this.callLLM(
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
conversationHistory,
|
conversationHistory,
|
||||||
message,
|
message,
|
||||||
|
scope,
|
||||||
);
|
);
|
||||||
|
|
||||||
return llmResponse;
|
return llmResponse;
|
||||||
@@ -66,27 +71,29 @@ class ChatbotService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSystemPrompt(insights: {
|
private buildSystemPrompt(scope: {
|
||||||
total_messages: number;
|
guildId?: string;
|
||||||
active_users: number;
|
channelId?: string;
|
||||||
flagged: number;
|
|
||||||
warned: number;
|
|
||||||
}): string {
|
}): string {
|
||||||
return `Kamu lagi ngobrol sama chatbot Discord Watcher — temen ngobrol yang tau keadaan server.
|
const scopeLine = scope.guildId
|
||||||
|
? `- Scope: kamu menjawab soal server/guild id="${scope.guildId}"${scope.channelId ? `, channel id="${scope.channelId}"` : ""}.`
|
||||||
|
: "- Scope: tidak ada guild spesifik — jawab umum soal server ini.";
|
||||||
|
return `Kamu adalah chatbot Discord Watcher — temen ngobrol yang tau keadaan server, dan kamu PUNYA AKSES ke data server lewat tools.
|
||||||
|
|
||||||
Data server saat ini:
|
${scopeLine}
|
||||||
- Pesan: ${insights.total_messages}
|
|
||||||
- User aktif: ${insights.active_users}
|
ATURAN PENTING — JANGAN PAKAI KONTEKS STATIS:
|
||||||
- Flagged: ${insights.flagged}
|
- Kamu TIDAK punya hafalan soal angka server (jumlah pesan, user aktif, flagged, dll). JANGAN tebak atau karang angka.
|
||||||
- Warning: ${insights.warned}
|
- Untuk SEMUA pertanyaan soal data server (jumlah pesan, user aktif, channel ramai, aktivitas terbaru, pesan di-flag), WAJIB panggil tool yang sesuai (get_server_stats, get_top_channels, get_recent_activity, get_top_flagged). Jawab HANYA dari hasil tool.
|
||||||
|
- Tool otomatis di-scope ke guild/channel di atas — kalau argumen guildId/channelId kosong, biarkan kosong (sudah otomatis ter-isi). Jangan isi ID yang kamu tebak.
|
||||||
|
- Kalau tool balas error atau kosong, bilang aja data lagi ga ketemu, jangan karang.
|
||||||
|
|
||||||
Gaya ngobrol:
|
Gaya ngobrol:
|
||||||
- Santai, hangat, kayak ngobrol sama temen
|
- Santai, hangat, kayak ngobrol sama temen
|
||||||
- Pake Bahasa Indonesia sehari-hari, ga perlu kaku
|
- Pake Bahasa Indonesia sehari-hari, ga perlu kaku
|
||||||
- Sesekali pake emoji wajar aja, ga berlebihan
|
- Sesekali pake emoji wajar aja, ga berlebihan
|
||||||
- Kalo ditanya sesuatu yang kamu tau dari data server, jawab pake data itu
|
- Kalo ditanya di luar data server dan kamu ga tau, bilang aja terus tanya balik biar ngobrolnya jalan
|
||||||
- Kalo ga tau atau ga nyambung, bilang aja terus tanya balik biar ngobrolnya jalan
|
- Jangan sebut "rule", "instruksi", "prompt", "tool", atau apapun soal cara kamu berpikir
|
||||||
- Jangan sebut "rule", "instruksi", "prompt" atau apapun soal cara kamu berpikir
|
|
||||||
- Biasa aja, ga usaha lucu-lucu amat — natural`;
|
- Biasa aja, ga usaha lucu-lucu amat — natural`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +113,7 @@ Gaya ngobrol:
|
|||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
history: Array<{ role: "user" | "assistant"; content: string }>,
|
history: Array<{ role: "user" | "assistant"; content: string }>,
|
||||||
userMessage: string,
|
userMessage: string,
|
||||||
|
scope: { guildId?: string; channelId?: string },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const apiKey = config.AI_LLM_API_KEY;
|
const apiKey = config.AI_LLM_API_KEY;
|
||||||
const baseUrl = config.AI_LLM_BASE_URL;
|
const baseUrl = config.AI_LLM_BASE_URL;
|
||||||
@@ -150,7 +158,13 @@ Gaya ngobrol:
|
|||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
max_tokens: 600,
|
max_tokens: 600,
|
||||||
temperature: 0.4,
|
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: {
|
headers: {
|
||||||
@@ -158,14 +172,16 @@ Gaya ngobrol:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
timeout: 45_000,
|
timeout: 45_000,
|
||||||
// 9router returns SSE even without stream:true; force stream:true
|
|
||||||
// in the body and read the raw SSE text.
|
|
||||||
responseType: "text",
|
responseType: "text",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse SSE `data:` lines → content + tool_calls.
|
// Parse the body into content + tool_calls. 9router may return either
|
||||||
const { content, toolCalls } = this.parseSse(response.data as string);
|
// 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(
|
logger.debug(
|
||||||
{
|
{
|
||||||
@@ -190,9 +206,19 @@ Gaya ngobrol:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
// Auto-scope: if the model omitted guildId/channelId, fill them
|
||||||
|
// from the request scope so tools query the right server without
|
||||||
|
// the model having to guess IDs.
|
||||||
|
const scopedArgs = { ...tc.args };
|
||||||
|
if (scope.guildId && scopedArgs.guildId == null) {
|
||||||
|
scopedArgs.guildId = scope.guildId;
|
||||||
|
}
|
||||||
|
if (scope.channelId && scopedArgs.channelId == null) {
|
||||||
|
scopedArgs.channelId = scope.channelId;
|
||||||
|
}
|
||||||
let result = "";
|
let result = "";
|
||||||
try {
|
try {
|
||||||
result = await executeTool(tc.name, tc.args);
|
result = await executeTool(tc.name, scopedArgs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result = `Tool error: ${(e as Error).message}`;
|
result = `Tool error: ${(e as Error).message}`;
|
||||||
}
|
}
|
||||||
@@ -224,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.
|
* Parse an SSE stream body into accumulated content + any tool_calls.
|
||||||
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines
|
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Static tool *definitions* for the chatbot LLM (OpenAI function-calling
|
||||||
|
* format). Kept separate from the executor (chatbot.tools.ts) so the schema
|
||||||
|
* the model depends on can be imported without pulling in the database /
|
||||||
|
* config layer.
|
||||||
|
*
|
||||||
|
* The chatbot is a server-watcher agent: it can answer about ANY server
|
||||||
|
* situation — activity, moderation queue, specific users, channels, voice
|
||||||
|
* recordings, AI correction history, and trends over time — by calling these
|
||||||
|
* tools, which the executor implements against real tables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ToolDef {
|
||||||
|
type: "function";
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: {
|
||||||
|
type: "object";
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: ToolDef[] = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_server_stats",
|
||||||
|
description:
|
||||||
|
"Ambil statistik ringkas server/guild: total pesan, user aktif, jumlah pesan flagged, warn, dan clean. Panggil untuk jawab pertanyaan umum soal kondisi server. guildId/channelId otomatis ter-isi dari scope; kosongkan untuk semua data.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_top_channels",
|
||||||
|
description:
|
||||||
|
"Ambil daftar channel paling aktif (jumlah pesan terbanyak). Panggil untuk 'channel mana paling ramai' atau aktivitas per-channel.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah channel teratas (default 5, max 10).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_recent_activity",
|
||||||
|
description:
|
||||||
|
"Ambil pesan terbaru di server: siapa, di channel mana, jam berapa, isinya. Panggil untuk 'lagi ngapain' / aktivitas terbaru.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah pesan terakhir (default 5, max 20).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_top_flagged",
|
||||||
|
description:
|
||||||
|
"Ambil pesan dengan ai_status flagged (beserta alasan, severity, analysis). Panggil untuk bahas pesan bermasalah / kerjaan moderator.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah pesan (default 5)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "search_messages",
|
||||||
|
description:
|
||||||
|
"Cari pesan berdasarkan kata kunci di isi pesan (case-insensitive, LIKE). Untuk 'ada yang bahas X gak?' / temukan topik tertentu. Hindari kata terlalu umum.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "Kata kunci pencarian (wajib).",
|
||||||
|
},
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah hasil (default 5)." },
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_user_messages",
|
||||||
|
description:
|
||||||
|
"Ambil pesan terbaru dari satu user tertentu (user_id), opsional di-scope ke guild/channel. Untuk 'chat si A gimana akhir-akhir ini?' — butuh user_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "ID user (wajib)." },
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah pesan (default 10)." },
|
||||||
|
},
|
||||||
|
required: ["userId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_user_profile",
|
||||||
|
description:
|
||||||
|
"Ambil ringkasan profil AI dari seorang user (pola perilaku, gaya bicara) dari tabel user_profiles. Untuk 'siapa si A?' / konteks perilaku. Butuh user_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "ID user (wajib)." },
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
},
|
||||||
|
required: ["userId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_user_reputation",
|
||||||
|
description:
|
||||||
|
"Ambil skor trust, jumlah infraction, dan streak pesan bersih seorang user dari user_reputations. Untuk 'berapa trust score si A?' / riwayat pelanggaran. Butuh user_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "ID user (wajib)." },
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
},
|
||||||
|
required: ["userId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_channel_culture",
|
||||||
|
description:
|
||||||
|
"Ambil ringkasan norma/slang channel dari tabel channel_cultures (AI-generated). Untuk 'norma channel ini gimana?' / konteks sebelum nge-flag. Butuh channel_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
channelId: { type: "string", description: "ID channel (wajib)." },
|
||||||
|
},
|
||||||
|
required: ["channelId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_message_detail",
|
||||||
|
description:
|
||||||
|
"Ambil 1 pesan lengkap beserta hasil analisis AI-nya (status, flags, score, severity, kategori, analysis, recommended action). Untuk jelasin keputusan moderasi pada pesan tertentu. Butuh message_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
messageId: { type: "string", description: "ID pesan (wajib)." },
|
||||||
|
},
|
||||||
|
required: ["messageId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_message_reviews",
|
||||||
|
description:
|
||||||
|
"Ambil antrean review moderasi manual (message_reviews) berdasarkan status: pending/approved/rejected/escalated. Untuk 'ada review moderasi pending?' / cek kerjaan human moderator. guildId otomatis ter-isi.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Status review: pending / approved / rejected / escalated (opsional, default semua).",
|
||||||
|
},
|
||||||
|
limit: { type: "number", description: "Jumlah (default 10)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_voice_recordings",
|
||||||
|
description:
|
||||||
|
"Ambil rekaman suara terbaru (voice_recordings): user, channel, transkripsi, status upload. Untuk 'ada rekaman suara terbaru?' / cek transkripsi. Bisa di-scope ke user_id atau channel_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "Filter user (opsional)." },
|
||||||
|
channelId: {
|
||||||
|
type: "string",
|
||||||
|
description: "Filter channel (opsional).",
|
||||||
|
},
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah (default 10)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_moderation_timeline",
|
||||||
|
description:
|
||||||
|
"Ambil tren harian: per hari, jumlah total pesan vs flagged vs warn vs clean. Untuk 'minggu ini pelanggaran naik?' / lihat tren moderasi. guildId otomatis ter-isi.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
days: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah hari ke belakang (default 14, max 60).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_corrections",
|
||||||
|
description:
|
||||||
|
"Ambil riwayat koreksi false-positive AI (corrected_moderations): pesan yang awalnya di-flag tapi dikoreksi manusia, beserta alasannya. Untuk 'AI pernah salah nge-flag apa aja?' / audit akurasi moderasi.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah (default 10)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,116 +1,25 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { and, desc, eq, like, sql } from "drizzle-orm";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
import {
|
||||||
|
pgChannelCulturesTable,
|
||||||
|
pgCorrectedModerationsTable,
|
||||||
|
pgMessageReviewsTable,
|
||||||
|
pgMessagesTable,
|
||||||
|
pgUserProfilesTable,
|
||||||
|
pgUserReputationsTable,
|
||||||
|
pgVoiceRecordingsTable,
|
||||||
|
} from "../../shared/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tools the chatbot LLM can call. Definitions describe the schema to the
|
* Executor for the chatbot's server-watcher tools. The tool *definitions*
|
||||||
* model; the executor implements each one against the real database.
|
* live in chatbot.toolDefs.ts (no DB import); this file implements each one
|
||||||
* This turns the chatbot from "blind stats guesser" into an agent that
|
* against the real database.
|
||||||
* pulls real, current server data on demand.
|
*
|
||||||
|
* All queries use parameterized drizzle operators (eq/like/and) — never string
|
||||||
|
* interpolation into raw SQL — so model-supplied arguments cannot inject SQL.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ToolResult = string;
|
export type ToolResult = string;
|
||||||
|
|
||||||
/** JSON schema for a tool definition (OpenAI function-calling format). */
|
|
||||||
export interface ToolDef {
|
|
||||||
type: "function";
|
|
||||||
function: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
parameters: {
|
|
||||||
type: "object";
|
|
||||||
properties: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tools: ToolDef[] = [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_server_stats",
|
|
||||||
description:
|
|
||||||
"Ambil statistik ringkas server/guild saat ini: total pesan, user aktif, jumlah pesan flagged, dan jumlah warning. Panggil ini untuk menjawab pertanyaan umum tentang kondisi server. Opsional fill guild_id untuk scope ke guild tertentu, channel_id untuk scope ke channel.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID guild/server (opsional). Kosongkan = semua data.",
|
|
||||||
},
|
|
||||||
channelId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID channel (opsional).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_top_channels",
|
|
||||||
description:
|
|
||||||
"Ambil daftar channel paling aktif (jumlah pesan terbanyak) di server. Panggil buat jawab 'channel mana paling ramai' atau aktivitas per-channel.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID server (opsional).",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "number",
|
|
||||||
description: "Jumlah channel teratas (default 5, max 10).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_recent_activity",
|
|
||||||
description:
|
|
||||||
"Ambil aktivitas/pesan terbaru di server: siapa yang baru ngomong, di channel mana, jam berapa. Panggil buat jawaban soal 'lagi ngapain' / aktivitas terbaru di server.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID server (opsional).",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "number",
|
|
||||||
description: "Jumlah pesan terakhir (default 5).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_top_flagged",
|
|
||||||
description:
|
|
||||||
"Ambil pesan yang paling sering di-flag atau kena warning. Panggil buat jawab soal pesan bermasalah / moderator.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID server (opsional).",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "number",
|
|
||||||
description: "Jumlah pesan (default 5).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Executes a tool call against the real DB and returns a readable result. */
|
/** Executes a tool call against the real DB and returns a readable result. */
|
||||||
export async function executeTool(
|
export async function executeTool(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -122,9 +31,11 @@ export async function executeTool(
|
|||||||
typeof args.channelId === "string" && args.channelId
|
typeof args.channelId === "string" && args.channelId
|
||||||
? args.channelId
|
? args.channelId
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const userId =
|
||||||
|
typeof args.userId === "string" && args.userId ? args.userId : undefined;
|
||||||
const limitRaw =
|
const limitRaw =
|
||||||
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
|
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
|
||||||
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 10);
|
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 20);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
@@ -133,9 +44,48 @@ export async function executeTool(
|
|||||||
case "get_top_channels":
|
case "get_top_channels":
|
||||||
return await topChannels(guildId, limit);
|
return await topChannels(guildId, limit);
|
||||||
case "get_recent_activity":
|
case "get_recent_activity":
|
||||||
return await recentActivity(guildId, limit);
|
return await recentActivity(guildId, channelId, limit);
|
||||||
case "get_top_flagged":
|
case "get_top_flagged":
|
||||||
return await topFlagged(guildId, limit);
|
return await topFlagged(guildId, channelId, limit);
|
||||||
|
case "search_messages":
|
||||||
|
return await searchMessages(
|
||||||
|
String(args.query ?? ""),
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
case "get_user_messages":
|
||||||
|
return await userMessages(userId, guildId, channelId, limit);
|
||||||
|
case "get_user_profile":
|
||||||
|
return await userProfile(userId, guildId);
|
||||||
|
case "get_user_reputation":
|
||||||
|
return await userReputation(userId, guildId);
|
||||||
|
case "get_channel_culture":
|
||||||
|
return await channelCulture(
|
||||||
|
typeof args.channelId === "string" ? args.channelId : undefined,
|
||||||
|
);
|
||||||
|
case "get_message_detail":
|
||||||
|
return await messageDetail(
|
||||||
|
typeof args.messageId === "string" ? args.messageId : undefined,
|
||||||
|
);
|
||||||
|
case "get_message_reviews":
|
||||||
|
return await messageReviews(
|
||||||
|
guildId,
|
||||||
|
typeof args.status === "string" ? args.status : undefined,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
case "get_voice_recordings":
|
||||||
|
return await voiceRecordings(userId, channelId, guildId, limit);
|
||||||
|
case "get_moderation_timeline":
|
||||||
|
return await moderationTimeline(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
typeof args.days === "number"
|
||||||
|
? Math.min(Math.max(1, args.days), 60)
|
||||||
|
: 14,
|
||||||
|
);
|
||||||
|
case "get_corrections":
|
||||||
|
return await corrections(guildId, limit);
|
||||||
default:
|
default:
|
||||||
return `Unknown tool: ${name}`;
|
return `Unknown tool: ${name}`;
|
||||||
}
|
}
|
||||||
@@ -145,6 +95,23 @@ export async function executeTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Query helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function scopeMessages(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
): ReturnType<typeof and> | undefined {
|
||||||
|
const conds = [];
|
||||||
|
if (guildId) conds.push(eq(pgMessagesTable.guild_id, guildId));
|
||||||
|
if (channelId) conds.push(eq(pgMessagesTable.channel_id, channelId));
|
||||||
|
return conds.length ? and(...conds) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape LIKE wildcards so user input can't break the pattern. */
|
||||||
|
function likePattern(q: string): string {
|
||||||
|
return q.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tool executors ──────────────────────────────────────────
|
// ── Tool executors ──────────────────────────────────────────
|
||||||
|
|
||||||
async function serverStats(
|
async function serverStats(
|
||||||
@@ -152,81 +119,330 @@ async function serverStats(
|
|||||||
channelId?: string,
|
channelId?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions: string[] = [];
|
const [result] = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
if (channelId) conditions.push(`channel_id = '${channelId}'`);
|
total_messages: sql<number>`COUNT(*)::int`,
|
||||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
active_users: sql<number>`COUNT(DISTINCT ${pgMessagesTable.user_id})::int`,
|
||||||
|
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
||||||
|
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
||||||
|
clean: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'clean')::int`,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(scopeMessages(guildId, channelId));
|
||||||
|
|
||||||
const result = await db.execute(
|
const r = result ?? {
|
||||||
sql.raw(
|
total_messages: 0,
|
||||||
`SELECT COUNT(*)::int AS total_messages,
|
active_users: 0,
|
||||||
COUNT(DISTINCT user_id)::int AS active_users,
|
flagged: 0,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
warned: 0,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned
|
clean: 0,
|
||||||
FROM messages ${cond}`,
|
};
|
||||||
),
|
return JSON.stringify(r);
|
||||||
);
|
|
||||||
const rows =
|
|
||||||
(result as unknown as { rows: Record<string, unknown>[] }).rows ?? [];
|
|
||||||
const r = rows[0] ?? {};
|
|
||||||
return JSON.stringify({
|
|
||||||
total_messages: r.total_messages ?? 0,
|
|
||||||
active_users: r.active_users ?? 0,
|
|
||||||
flagged: r.flagged ?? 0,
|
|
||||||
warned: r.warned ?? 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function topChannels(guildId?: string, limit = 5): Promise<string> {
|
async function topChannels(guildId?: string, limit = 5): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions: string[] = [];
|
const rows = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
count: sql<number>`COUNT(*)::int`,
|
||||||
const result = await db.execute(
|
})
|
||||||
sql.raw(
|
.from(pgMessagesTable)
|
||||||
`SELECT channel_id,
|
.where(scopeMessages(guildId))
|
||||||
COUNT(*)::int AS count
|
.groupBy(pgMessagesTable.channel_id)
|
||||||
FROM messages ${cond}
|
.orderBy(desc(sql`COUNT(*)`))
|
||||||
GROUP BY channel_id
|
.limit(limit);
|
||||||
ORDER BY count DESC
|
return JSON.stringify(rows);
|
||||||
LIMIT ${limit}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const rows = (result as unknown as { rows: unknown[] }).rows ?? [];
|
|
||||||
return JSON.stringify(rows.slice(0, limit));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recentActivity(guildId?: string, limit = 5): Promise<string> {
|
async function recentActivity(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions: string[] = [];
|
const rows = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
id: pgMessagesTable.id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
const result = await db.execute(
|
user_id: pgMessagesTable.user_id,
|
||||||
sql.raw(
|
channel_id: pgMessagesTable.channel_id,
|
||||||
`SELECT username, content, channel_id, created_at
|
content: pgMessagesTable.content,
|
||||||
FROM messages ${cond}
|
created_at: pgMessagesTable.created_at,
|
||||||
ORDER BY created_at DESC
|
ai_status: pgMessagesTable.ai_status,
|
||||||
LIMIT ${limit}`,
|
})
|
||||||
),
|
.from(pgMessagesTable)
|
||||||
);
|
.where(scopeMessages(guildId, channelId))
|
||||||
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function topFlagged(guildId?: string, limit = 5): Promise<string> {
|
async function topFlagged(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions = ["ai_status IN ('flagged', 'warn')"];
|
const rows = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
const cond = `WHERE ${conditions.join(" AND ")}`;
|
id: pgMessagesTable.id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
const result = await db.execute(
|
channel_id: pgMessagesTable.channel_id,
|
||||||
sql.raw(
|
content: pgMessagesTable.content,
|
||||||
`SELECT username, content, channel_id, ai_status, created_at
|
ai_status: pgMessagesTable.ai_status,
|
||||||
FROM messages ${cond}
|
ai_severity: pgMessagesTable.ai_severity,
|
||||||
ORDER BY created_at DESC
|
ai_moderation_flags: pgMessagesTable.ai_moderation_flags,
|
||||||
LIMIT ${limit}`,
|
ai_analysis: pgMessagesTable.ai_analysis,
|
||||||
),
|
created_at: pgMessagesTable.created_at,
|
||||||
);
|
})
|
||||||
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
|
.from(pgMessagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
scopeMessages(guildId, channelId),
|
||||||
|
eq(pgMessagesTable.ai_status, "flagged"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMessages(
|
||||||
|
query: string,
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!query.trim()) return JSON.stringify({ error: "query kosong" });
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessagesTable.id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
content: pgMessagesTable.content,
|
||||||
|
created_at: pgMessagesTable.created_at,
|
||||||
|
ai_status: pgMessagesTable.ai_status,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
scopeMessages(guildId, channelId),
|
||||||
|
like(pgMessagesTable.content, `%${likePattern(query)}%`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userMessages(
|
||||||
|
userId?: string,
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
||||||
|
const conds = [eq(pgMessagesTable.user_id, userId)];
|
||||||
|
if (guildId) conds.push(eq(pgMessagesTable.guild_id, guildId));
|
||||||
|
if (channelId) conds.push(eq(pgMessagesTable.channel_id, channelId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessagesTable.id,
|
||||||
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
content: pgMessagesTable.content,
|
||||||
|
created_at: pgMessagesTable.created_at,
|
||||||
|
ai_status: pgMessagesTable.ai_status,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(and(...conds))
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userProfile(userId?: string, guildId?: string): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
||||||
|
const conds = [eq(pgUserProfilesTable.user_id, userId)];
|
||||||
|
if (guildId) conds.push(eq(pgUserProfilesTable.guild_id, guildId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
user_id: pgUserProfilesTable.user_id,
|
||||||
|
guild_id: pgUserProfilesTable.guild_id,
|
||||||
|
profile_summary: pgUserProfilesTable.profile_summary,
|
||||||
|
last_analyzed_at: pgUserProfilesTable.last_analyzed_at,
|
||||||
|
})
|
||||||
|
.from(pgUserProfilesTable)
|
||||||
|
.where(and(...conds))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "profil tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userReputation(
|
||||||
|
userId?: string,
|
||||||
|
guildId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
||||||
|
const conds = [eq(pgUserReputationsTable.user_id, userId)];
|
||||||
|
if (guildId) conds.push(eq(pgUserReputationsTable.guild_id, guildId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
user_id: pgUserReputationsTable.user_id,
|
||||||
|
guild_id: pgUserReputationsTable.guild_id,
|
||||||
|
trust_score: pgUserReputationsTable.trust_score,
|
||||||
|
clean_message_streak: pgUserReputationsTable.clean_message_streak,
|
||||||
|
total_infractions: pgUserReputationsTable.total_infractions,
|
||||||
|
last_infraction_at: pgUserReputationsTable.last_infraction_at,
|
||||||
|
})
|
||||||
|
.from(pgUserReputationsTable)
|
||||||
|
.where(and(...conds))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "reputasi tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function channelCulture(channelId?: string): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!channelId) return JSON.stringify({ error: "channelId wajib" });
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
channel_id: pgChannelCulturesTable.channel_id,
|
||||||
|
culture_summary: pgChannelCulturesTable.culture_summary,
|
||||||
|
last_analyzed_at: pgChannelCulturesTable.last_analyzed_at,
|
||||||
|
})
|
||||||
|
.from(pgChannelCulturesTable)
|
||||||
|
.where(eq(pgChannelCulturesTable.channel_id, channelId))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "culture tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function messageDetail(messageId?: string): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!messageId) return JSON.stringify({ error: "messageId wajib" });
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessagesTable.id,
|
||||||
|
guild_id: pgMessagesTable.guild_id,
|
||||||
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
user_id: pgMessagesTable.user_id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
|
content: pgMessagesTable.content,
|
||||||
|
created_at: pgMessagesTable.created_at,
|
||||||
|
ai_status: pgMessagesTable.ai_status,
|
||||||
|
ai_moderation_flags: pgMessagesTable.ai_moderation_flags,
|
||||||
|
ai_moderation_score: pgMessagesTable.ai_moderation_score,
|
||||||
|
ai_severity: pgMessagesTable.ai_severity,
|
||||||
|
ai_categories: pgMessagesTable.ai_categories,
|
||||||
|
ai_analysis: pgMessagesTable.ai_analysis,
|
||||||
|
ai_recommended_action: pgMessagesTable.ai_recommended_action,
|
||||||
|
ai_confidence: pgMessagesTable.ai_confidence,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(eq(pgMessagesTable.id, messageId))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "pesan tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function messageReviews(
|
||||||
|
guildId?: string,
|
||||||
|
status?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conds = [];
|
||||||
|
if (guildId) conds.push(eq(pgMessageReviewsTable.guild_id, guildId));
|
||||||
|
if (status) conds.push(eq(pgMessageReviewsTable.status, status as never));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessageReviewsTable.id,
|
||||||
|
message_id: pgMessageReviewsTable.message_id,
|
||||||
|
reviewer_id: pgMessageReviewsTable.reviewer_id,
|
||||||
|
status: pgMessageReviewsTable.status,
|
||||||
|
notes: pgMessageReviewsTable.notes,
|
||||||
|
created_at: pgMessageReviewsTable.created_at,
|
||||||
|
reviewed_at: pgMessageReviewsTable.reviewed_at,
|
||||||
|
})
|
||||||
|
.from(pgMessageReviewsTable)
|
||||||
|
.where(conds.length ? and(...conds) : undefined)
|
||||||
|
.orderBy(desc(pgMessageReviewsTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function voiceRecordings(
|
||||||
|
userId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
guildId?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conds = [];
|
||||||
|
if (userId) conds.push(eq(pgVoiceRecordingsTable.user_id, userId));
|
||||||
|
if (channelId) conds.push(eq(pgVoiceRecordingsTable.channel_id, channelId));
|
||||||
|
if (guildId) conds.push(eq(pgVoiceRecordingsTable.guild_id, guildId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgVoiceRecordingsTable.id,
|
||||||
|
username: pgVoiceRecordingsTable.username,
|
||||||
|
channel_name: pgVoiceRecordingsTable.channel_name,
|
||||||
|
filename: pgVoiceRecordingsTable.filename,
|
||||||
|
size_bytes: pgVoiceRecordingsTable.size_bytes,
|
||||||
|
upload_status: pgVoiceRecordingsTable.upload_status,
|
||||||
|
transcription: pgVoiceRecordingsTable.transcription,
|
||||||
|
created_at: pgVoiceRecordingsTable.created_at,
|
||||||
|
})
|
||||||
|
.from(pgVoiceRecordingsTable)
|
||||||
|
.where(conds.length ? and(...conds) : undefined)
|
||||||
|
.orderBy(desc(pgVoiceRecordingsTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moderationTimeline(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
days = 14,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const day = sql<string>`to_char(to_timestamp(${pgMessagesTable.created_at} / 1000), 'YYYY-MM-DD')`;
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
day,
|
||||||
|
total: sql<number>`COUNT(*)::int`,
|
||||||
|
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
||||||
|
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
||||||
|
clean: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'clean')::int`,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
scopeMessages(guildId, channelId),
|
||||||
|
// only the last N days
|
||||||
|
sql`${pgMessagesTable.created_at} >= extract(epoch FROM now() - (${days} || ' days')::interval) * 1000`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(day)
|
||||||
|
.orderBy(day);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function corrections(_guildId?: string, limit = 10): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgCorrectedModerationsTable.id,
|
||||||
|
message_id: pgCorrectedModerationsTable.message_id,
|
||||||
|
original_flags: pgCorrectedModerationsTable.original_flags,
|
||||||
|
corrected_flags: pgCorrectedModerationsTable.corrected_flags,
|
||||||
|
correction_notes: pgCorrectedModerationsTable.correction_notes,
|
||||||
|
content_snippet: pgCorrectedModerationsTable.content_snippet,
|
||||||
|
created_at: pgCorrectedModerationsTable.created_at,
|
||||||
|
})
|
||||||
|
.from(pgCorrectedModerationsTable)
|
||||||
|
.orderBy(desc(pgCorrectedModerationsTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { createChatbotRouter } from "./chatbot.routes.js";
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { config } from "../../shared/config/index.js";
|
|
||||||
|
|
||||||
export function createConfigRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/config
|
|
||||||
router.get("/config", (_req, res) => {
|
|
||||||
res.json({
|
|
||||||
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
|
||||||
webserverPort: config.WEBSERVER_PORT,
|
|
||||||
nodeEnv: config.NODE_ENV,
|
|
||||||
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
|
|
||||||
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
|
||||||
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
|
||||||
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
|
||||||
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
|
|
||||||
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
|
||||||
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
|
||||||
voiceGuildId: config.VOICE_GUILD_ID || null,
|
|
||||||
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
|
||||||
logLevel: config.LOG_LEVEL,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createConfigRouter } from "./config.routes.js";
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
pgChannelCulturesTable,
|
pgChannelCulturesTable,
|
||||||
pgMessagesTable,
|
pgMessagesTable,
|
||||||
pgUserProfilesTable,
|
pgUserProfilesTable,
|
||||||
pgUserReputationsTable,
|
|
||||||
pgVoiceRecordingsTable,
|
pgVoiceRecordingsTable,
|
||||||
} from "../../shared/index.js";
|
} from "../../shared/index.js";
|
||||||
import type { ListUsersQuery } from "./dashboard.service.js";
|
import type { ListUsersQuery } from "./dashboard.service.js";
|
||||||
@@ -156,8 +155,7 @@ export class DashboardRepository {
|
|||||||
p.profile_summary,
|
p.profile_summary,
|
||||||
m.total_messages,
|
m.total_messages,
|
||||||
m.flagged_count,
|
m.flagged_count,
|
||||||
m.last_message_at,
|
m.last_message_at
|
||||||
r.trust_score
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -170,7 +168,6 @@ export class DashboardRepository {
|
|||||||
GROUP BY user_id, username, avatar_url
|
GROUP BY user_id, username, avatar_url
|
||||||
) m
|
) m
|
||||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY m.last_message_at DESC NULLS LAST
|
ORDER BY m.last_message_at DESC NULLS LAST
|
||||||
LIMIT ${limit + 1}
|
LIMIT ${limit + 1}
|
||||||
@@ -186,10 +183,6 @@ export class DashboardRepository {
|
|||||||
total_messages: Number(r.total_messages),
|
total_messages: Number(r.total_messages),
|
||||||
flagged_count: Number(r.flagged_count),
|
flagged_count: Number(r.flagged_count),
|
||||||
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
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;
|
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
||||||
@@ -437,10 +430,7 @@ export class DashboardRepository {
|
|||||||
m.flagged_count,
|
m.flagged_count,
|
||||||
m.clean_count,
|
m.clean_count,
|
||||||
p.profile_summary,
|
p.profile_summary,
|
||||||
p.last_analyzed_at,
|
p.last_analyzed_at
|
||||||
r.trust_score,
|
|
||||||
r.clean_message_streak,
|
|
||||||
r.total_infractions
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -454,7 +444,6 @@ export class DashboardRepository {
|
|||||||
GROUP BY user_id, username, avatar_url
|
GROUP BY user_id, username, avatar_url
|
||||||
) m
|
) m
|
||||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
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;
|
const row = userResult.rows[0] as Record<string, unknown> | undefined;
|
||||||
@@ -481,13 +470,6 @@ export class DashboardRepository {
|
|||||||
last_analyzed_at: row.last_analyzed_at
|
last_analyzed_at: row.last_analyzed_at
|
||||||
? Number(row.last_analyzed_at)
|
? Number(row.last_analyzed_at)
|
||||||
: null,
|
: 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) => ({
|
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
|
||||||
id: String(r.id),
|
id: String(r.id),
|
||||||
content: String(r.content),
|
content: String(r.content),
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { dashboardService } from "./dashboard.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("dashboard.routes");
|
|
||||||
|
|
||||||
export function createDashboardRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/dashboard/stats — aggregated server statistics
|
|
||||||
router.get(
|
|
||||||
"/dashboard/stats",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Fetching dashboard stats");
|
|
||||||
const stats = await dashboardService.getStats();
|
|
||||||
res.json(stats);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/activity?days=14 — message volume over time
|
|
||||||
router.get(
|
|
||||||
"/dashboard/activity",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const days = Math.min(Math.max(Number(req.query.days) || 14, 1), 90);
|
|
||||||
const activity = await dashboardService.getActivity(days);
|
|
||||||
res.json(activity);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/users — paginated user list with profiles
|
|
||||||
router.get(
|
|
||||||
"/dashboard/users",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const cursor =
|
|
||||||
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
|
|
||||||
const search =
|
|
||||||
typeof req.query.search === "string" ? req.query.search : undefined;
|
|
||||||
|
|
||||||
const result = await dashboardService.listUsers({
|
|
||||||
limit,
|
|
||||||
cursor,
|
|
||||||
search,
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/users/:userId — single user detail
|
|
||||||
router.get(
|
|
||||||
"/dashboard/users/:userId",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const userId = String(req.params.userId);
|
|
||||||
const detail = await dashboardService.getUserDetail(userId);
|
|
||||||
res.json(detail);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/channels — paginated channel list with culture summaries
|
|
||||||
router.get(
|
|
||||||
"/dashboard/channels",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const search =
|
|
||||||
typeof req.query.search === "string" ? req.query.search : undefined;
|
|
||||||
const guildId =
|
|
||||||
typeof req.query.guild_id === "string" ? req.query.guild_id : undefined;
|
|
||||||
|
|
||||||
const result = await dashboardService.listChannels({
|
|
||||||
limit,
|
|
||||||
search,
|
|
||||||
guildId,
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/channels/:channelId — single channel detail
|
|
||||||
router.get(
|
|
||||||
"/dashboard/channels/:channelId",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const channelId = String(req.params.channelId);
|
|
||||||
const detail = await dashboardService.getChannelDetail(channelId);
|
|
||||||
res.json(detail);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/reactions — top reacted messages
|
|
||||||
router.get(
|
|
||||||
"/dashboard/reactions",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const reactions = await dashboardService.getTopReactions(limit);
|
|
||||||
res.json(reactions);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/reactors — top users by reactions given
|
|
||||||
router.get(
|
|
||||||
"/dashboard/reactors",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const reactors = await dashboardService.getTopReactors(limit);
|
|
||||||
res.json(reactors);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createDashboardRouter } from "./dashboard.routes.js";
|
|
||||||
@@ -67,9 +67,9 @@ export const moderationErrors = new Counter({
|
|||||||
labelNames: ["type"] as const,
|
labelNames: ["type"] as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const searxngCalls = new Counter({
|
export const webSearchCalls = new Counter({
|
||||||
name: "moderation_searxng_calls_total",
|
name: "moderation_websearch_calls_total",
|
||||||
help: "SearXNG search calls",
|
help: "Wikipedia web-search calls",
|
||||||
labelNames: ["status"] as const,
|
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();
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createMediaRouter } from "./media.routes.js";
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
|
||||||
import { mediaLoopSchema, mediaQueueSchema } from "./media.schema.js";
|
|
||||||
import { getStatus, queue, setLoop, skip, stop } from "./media.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("media.routes");
|
|
||||||
|
|
||||||
export function createMediaRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/media/status
|
|
||||||
router.get(
|
|
||||||
"/media/status",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Media status requested");
|
|
||||||
const status = await getStatus();
|
|
||||||
res.json(status);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/queue
|
|
||||||
router.post(
|
|
||||||
"/media/queue",
|
|
||||||
validateBody(mediaQueueSchema),
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const { source, mode } = req.body as {
|
|
||||||
source: string;
|
|
||||||
mode: "music" | "screen";
|
|
||||||
};
|
|
||||||
logger.debug({ source, mode }, "Media queue requested");
|
|
||||||
const state = await queue(source, mode);
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/skip
|
|
||||||
router.post(
|
|
||||||
"/media/skip",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Media skip requested");
|
|
||||||
const state = await skip();
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/stop
|
|
||||||
router.post(
|
|
||||||
"/media/stop",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Media stop requested");
|
|
||||||
const state = await stop();
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/loop
|
|
||||||
router.post(
|
|
||||||
"/media/loop",
|
|
||||||
validateBody(mediaLoopSchema),
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const { loop } = req.body as { loop: boolean };
|
|
||||||
logger.debug({ loop }, "Media loop requested");
|
|
||||||
const state = await setLoop(loop);
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createMessagesRouter } from "./messages.routes.js";
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { messageQuerySchema } from "./messages.schema.js";
|
|
||||||
import { messagesService } from "./messages.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("messages.controller");
|
|
||||||
|
|
||||||
export const handleListMessages = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const query = messageQuerySchema.parse(req.query);
|
|
||||||
logger.debug({ query }, "Handling list messages request");
|
|
||||||
const result = await messagesService.listMessages(query);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetMessagesByChannel = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.params.channelId) {
|
|
||||||
res.status(400).json({ error: "Missing route parameter: channelId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const channelId = req.params.channelId as string;
|
|
||||||
const query = messageQuerySchema.parse(req.query);
|
|
||||||
logger.debug({ channelId, query }, "Handling get messages by channel");
|
|
||||||
const result = await messagesService.getMessagesByChannel(channelId, query);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetMessageById = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.params.id) {
|
|
||||||
res.status(400).json({ error: "Missing route parameter: id" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const id = req.params.id as string;
|
|
||||||
logger.debug({ id }, "Handling get message by ID");
|
|
||||||
const result = await messagesService.getMessageById(id);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetImageMessages = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const guildId = req.query.guildId as string | undefined;
|
|
||||||
if (!guildId) {
|
|
||||||
res.status(400).json({ error: "Missing query parameter: guildId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const limit = Number(req.query.limit) || 50;
|
|
||||||
logger.debug({ guildId, limit }, "Handling get image messages");
|
|
||||||
const result = await messagesService.getImageMessages(guildId, limit);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetAttachmentsByChannel = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.params.channelId) {
|
|
||||||
res.status(400).json({ error: "Missing route parameter: channelId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const channelId = req.params.channelId as string;
|
|
||||||
const query = messageQuerySchema.parse(req.query);
|
|
||||||
logger.debug({ channelId, query }, "Handling get attachments by channel");
|
|
||||||
const result = await messagesService.getAttachmentsByChannel(
|
|
||||||
channelId,
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -77,12 +77,11 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
||||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
conditions.push(
|
const excludeThreads = or(
|
||||||
or(
|
isNull(pgMessagesTable.thread_id),
|
||||||
isNull(pgMessagesTable.thread_id),
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
|
||||||
)!,
|
|
||||||
);
|
);
|
||||||
|
if (excludeThreads) conditions.push(excludeThreads);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
@@ -150,12 +149,11 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
// Exclude spam threads (NULL-safe)
|
// Exclude spam threads (NULL-safe)
|
||||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
conditions.push(
|
const excludeThreads = or(
|
||||||
or(
|
isNull(pgMessagesTable.thread_id),
|
||||||
isNull(pgMessagesTable.thread_id),
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
|
||||||
)!,
|
|
||||||
);
|
);
|
||||||
|
if (excludeThreads) conditions.push(excludeThreads);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -174,6 +172,71 @@ export class MessagesRepository {
|
|||||||
return { data, nextCursor };
|
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) {
|
async create(data: MessageCreate) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
@@ -316,12 +379,13 @@ export class MessagesRepository {
|
|||||||
like(pgAttachmentsTable.type, "image/%"),
|
like(pgAttachmentsTable.type, "image/%"),
|
||||||
// Exclude spam threads (NULL-safe for non-thread messages)
|
// Exclude spam threads (NULL-safe for non-thread messages)
|
||||||
...(EXCLUDED_THREAD_IDS.length > 0
|
...(EXCLUDED_THREAD_IDS.length > 0
|
||||||
? [
|
? (() => {
|
||||||
or(
|
const excludeThreads = or(
|
||||||
isNull(pgAttachmentsTable.thread_id),
|
isNull(pgAttachmentsTable.thread_id),
|
||||||
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
)!,
|
);
|
||||||
]
|
return excludeThreads ? [excludeThreads] : [];
|
||||||
|
})()
|
||||||
: []),
|
: []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -395,6 +459,69 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
return { data: trimmed, nextCursor };
|
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();
|
export const messagesRepository = new MessagesRepository();
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import {
|
|
||||||
handleGetAttachmentsByChannel,
|
|
||||||
handleGetImageMessages,
|
|
||||||
handleGetMessageById,
|
|
||||||
handleGetMessagesByChannel,
|
|
||||||
handleListMessages,
|
|
||||||
} from "./messages.controller.js";
|
|
||||||
import { messagesService } from "./messages.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("messages.routes");
|
|
||||||
|
|
||||||
export function createMessagesRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/messages/images - Get messages with image attachments
|
|
||||||
// MUST be registered BEFORE /messages/:channelId so "images" is not
|
|
||||||
// captured as a channelId param.
|
|
||||||
router.get("/messages/images", handleGetImageMessages);
|
|
||||||
|
|
||||||
// GET /api/messages - List messages
|
|
||||||
router.get("/messages", handleListMessages);
|
|
||||||
|
|
||||||
// GET /api/messages/:channelId - Get messages by channel
|
|
||||||
router.get("/messages/:channelId", handleGetMessagesByChannel);
|
|
||||||
|
|
||||||
// GET /api/messages/:channelId/attachments - Get attachments by channel
|
|
||||||
router.get("/messages/:channelId/attachments", handleGetAttachmentsByChannel);
|
|
||||||
|
|
||||||
// GET /api/messages/detail/:id - Get single message by ID
|
|
||||||
// (uses /detail/ prefix to avoid collision with :channelId route above)
|
|
||||||
router.get("/messages/detail/:id", handleGetMessageById);
|
|
||||||
|
|
||||||
// GET /api/review - Get flagged/warned messages for review
|
|
||||||
router.get(
|
|
||||||
"/review",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const channelId = (req.query.channelId as string) || undefined;
|
|
||||||
|
|
||||||
const rows = await messagesService.getReviewMessages(channelId, limit);
|
|
||||||
logger.debug({ limit, channelId }, "Review query executed");
|
|
||||||
res.json({ results: rows, limit, cursor: null });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -41,3 +41,11 @@ export const messageUpdateSchema = z.object({
|
|||||||
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
||||||
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
||||||
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
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 { NotFoundError, ValidationError } from "@/shared/errors/index";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { embedQuery } from "./embed.js";
|
||||||
import { messagesRepository } from "./messages.repository.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");
|
const logger = createChildLogger("messages.service");
|
||||||
|
|
||||||
@@ -15,6 +17,14 @@ export class MessagesService {
|
|||||||
return messagesRepository.findMany(query);
|
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) {
|
async getMessagesByChannel(channelId: string, query: MessageQuery) {
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
throw new ValidationError("channelId is required");
|
throw new ValidationError("channelId is required");
|
||||||
@@ -70,6 +80,49 @@ export class MessagesService {
|
|||||||
logger.debug({ channelId, limit }, "Getting review messages");
|
logger.debug({ channelId, limit }, "Getting review messages");
|
||||||
return messagesRepository.getReviewMessages(channelId, limit);
|
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();
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createModerationRouter } from "./moderation.routes.js";
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user