feat(gmw): public features #2-#6 — live moderation feed, toxic topic trends, channel timeline, CSV export, activity heatmap
- Live Moderation Feed: gateway publishes discord:moderation:action (Redis) → backend WS emits moderation_action → public web shows realtime stream. - Toxic Topic Trends: backend moderation.trends aggregates categories/severity/action_type (read-only) → SVG bar + donut. - Channel Timeline: messages view gets Feed/Timeline toggle with date-grouped separators. - CSV Export: client-side downloadCsv for moderation actions (no backend write scope). - Activity Heatmap: backend messages.activity (per-hour volume by channel) → pure-SVG grid. User reputation deliberately excluded — no such feature exists in the codebase. All read-only / public-facing / fully automatic per project rules.
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
# 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/`
|
||||||
@@ -459,6 +459,31 @@ 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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const messagesRepository = new MessagesRepository();
|
export const messagesRepository = new MessagesRepository();
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ export class MessagesService {
|
|||||||
const results = hits.map((h) => mapSearchHit(h));
|
const results = hits.map((h) => mapSearchHit(h));
|
||||||
return { results, nextCursor: null };
|
return { results, nextCursor: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getActivity(days = 30) {
|
||||||
|
return messagesRepository.getActivity(days);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shape returned to the frontend (text + metadata from the archive payload). */
|
/** Shape returned to the frontend (text + metadata from the archive payload). */
|
||||||
|
|||||||
@@ -164,6 +164,60 @@ export class ModerationRepository {
|
|||||||
|
|
||||||
return { data, nextCursor };
|
return { data, nextCursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate moderation trends over the last `days` days.
|
||||||
|
* - category counts (from the jsonb/text[] `categories` column, unnested)
|
||||||
|
* - severity distribution
|
||||||
|
* - action_type distribution
|
||||||
|
* Read-only; powers the public Toxic Topic Trends panel.
|
||||||
|
*/
|
||||||
|
async getTrends(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const cats = await db.execute(sql`
|
||||||
|
SELECT jsonb_array_elements_text(a.categories::jsonb) AS cat, COUNT(*)::int AS c
|
||||||
|
FROM moderation_actions a
|
||||||
|
WHERE a.created_at >= ${since} AND a.categories IS NOT NULL AND a.categories != '[]' AND a.categories != ''
|
||||||
|
GROUP BY cat
|
||||||
|
ORDER BY c DESC
|
||||||
|
LIMIT 15
|
||||||
|
`);
|
||||||
|
const catRows = (cats.rows as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
const sev = await db.execute(sql`
|
||||||
|
SELECT severity, COUNT(*)::int AS c
|
||||||
|
FROM moderation_actions
|
||||||
|
WHERE created_at >= ${since} AND severity IS NOT NULL
|
||||||
|
GROUP BY severity
|
||||||
|
`);
|
||||||
|
const sevRows = (sev.rows as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
const act = await db.execute(sql`
|
||||||
|
SELECT action_type, COUNT(*)::int AS c
|
||||||
|
FROM moderation_actions
|
||||||
|
WHERE created_at >= ${since}
|
||||||
|
GROUP BY action_type
|
||||||
|
ORDER BY c DESC
|
||||||
|
`);
|
||||||
|
const actRows = (act.rows as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories: catRows.map((r) => ({
|
||||||
|
name: String(r.cat),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
})),
|
||||||
|
severities: sevRows.map((r) => ({
|
||||||
|
level: String(r.severity),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
})),
|
||||||
|
actions: actRows.map((r) => ({
|
||||||
|
type: String(r.action_type),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const moderationRepository = new ModerationRepository();
|
export const moderationRepository = new ModerationRepository();
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ const logger = createChildLogger("moderation.service");
|
|||||||
|
|
||||||
export class ModerationService {
|
export class ModerationService {
|
||||||
async getStats() {
|
async getStats() {
|
||||||
logger.debug("Fetching moderation stats");
|
|
||||||
return moderationRepository.getStats();
|
return moderationRepository.getStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTrends(days = 30) {
|
||||||
|
return moderationRepository.getTrends(days);
|
||||||
|
}
|
||||||
|
|
||||||
async listActions(query: ListModerationQuery) {
|
async listActions(query: ListModerationQuery) {
|
||||||
logger.debug({ query }, "Listing moderation actions");
|
logger.debug({ query }, "Listing moderation actions");
|
||||||
return moderationRepository.listActions(query);
|
return moderationRepository.listActions(query);
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ const messagesRouter = {
|
|||||||
semanticSearch: os
|
semanticSearch: os
|
||||||
.input(semanticSearchSchema)
|
.input(semanticSearchSchema)
|
||||||
.handler(({ input }) => messagesService.semanticSearch(input)),
|
.handler(({ input }) => messagesService.semanticSearch(input)),
|
||||||
|
// Public, read-only activity heatmap data (per-hour volume by channel).
|
||||||
|
activity: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => messagesService.getActivity(input.days)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Moderation ───────────────────────────────────────────────────
|
// ── Moderation ───────────────────────────────────────────────────
|
||||||
@@ -165,6 +173,13 @@ const moderationRouter = {
|
|||||||
cursor: input.cursor,
|
cursor: input.cursor,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
trends: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => moderationService.getTrends(input.days)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Media ────────────────────────────────────────────────────────
|
// ── Media ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const DISCORD_CHANNEL_TOPIC_UPDATED = "discord:channel:topic_updated";
|
|||||||
export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated";
|
export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated";
|
||||||
export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added";
|
export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added";
|
||||||
export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed";
|
export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed";
|
||||||
|
export const DISCORD_MODERATION_ACTION = "discord:moderation:action";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Command channels (backend -> discord-gateway)
|
// Command channels (backend -> discord-gateway)
|
||||||
@@ -126,4 +127,5 @@ export const DISCORD_CHANNEL_TO_WS_EVENT: Record<string, string> = {
|
|||||||
[DISCORD_PRESENCE_UPDATED]: "presence_updated",
|
[DISCORD_PRESENCE_UPDATED]: "presence_updated",
|
||||||
[DISCORD_GUILD_MEMBER_ADDED]: "guild_member_added",
|
[DISCORD_GUILD_MEMBER_ADDED]: "guild_member_added",
|
||||||
[DISCORD_GUILD_MEMBER_REMOVED]: "guild_member_removed",
|
[DISCORD_GUILD_MEMBER_REMOVED]: "guild_member_removed",
|
||||||
|
[DISCORD_MODERATION_ACTION]: "moderation_action",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
registerMessageCapture,
|
registerMessageCapture,
|
||||||
setEventBroadcaster as setMessageCaptureEventBroadcaster,
|
setEventBroadcaster as setMessageCaptureEventBroadcaster,
|
||||||
} from "../modules/message-capture/messageCapture.js";
|
} from "../modules/message-capture/messageCapture.js";
|
||||||
|
import { setModerationEventBroadcaster } from "../modules/message-capture/moderationActionsDb.js";
|
||||||
import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
|
import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
|
||||||
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
||||||
import { registerPresenceCapture } from "../modules/user-presence/index.js";
|
import { registerPresenceCapture } from "../modules/user-presence/index.js";
|
||||||
@@ -254,6 +255,7 @@ export async function initializeDiscordGateway() {
|
|||||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||||
setMessageCaptureEventBroadcaster(eventBroadcaster);
|
setMessageCaptureEventBroadcaster(eventBroadcaster);
|
||||||
setRecorderEventBroadcaster(eventBroadcaster);
|
setRecorderEventBroadcaster(eventBroadcaster);
|
||||||
|
setModerationEventBroadcaster(eventBroadcaster);
|
||||||
registerMessageCapture(client);
|
registerMessageCapture(client);
|
||||||
startPendingAIAnalysisWorker(client, eventBroadcaster);
|
startPendingAIAnalysisWorker(client, eventBroadcaster);
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,16 @@ export class EventBroadcaster {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async moderationAction(data: Record<string, unknown>): Promise<void> {
|
||||||
|
this.logger.debug({ data }, "Publishing moderation_action");
|
||||||
|
await this.publisher.publish(EventChannels.MODERATION_ACTION, {
|
||||||
|
type: "moderation_action",
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: "discord-gateway",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async analysisQueueStatus(data: Record<string, unknown>): Promise<void> {
|
async analysisQueueStatus(data: Record<string, unknown>): Promise<void> {
|
||||||
this.logger.debug({ data }, "Publishing analysis_queue_status");
|
this.logger.debug({ data }, "Publishing analysis_queue_status");
|
||||||
await this.publisher.publish(EventChannels.ANALYSIS_QUEUE_STATUS, {
|
await this.publisher.publish(EventChannels.ANALYSIS_QUEUE_STATUS, {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DISCORD_MESSAGE_CREATED,
|
DISCORD_MESSAGE_CREATED,
|
||||||
DISCORD_MESSAGE_DELETED,
|
DISCORD_MESSAGE_DELETED,
|
||||||
DISCORD_MESSAGE_UPDATED,
|
DISCORD_MESSAGE_UPDATED,
|
||||||
|
DISCORD_MODERATION_ACTION,
|
||||||
DISCORD_PRESENCE_UPDATED,
|
DISCORD_PRESENCE_UPDATED,
|
||||||
DISCORD_REACTION_ADDED,
|
DISCORD_REACTION_ADDED,
|
||||||
DISCORD_REACTION_REMOVED,
|
DISCORD_REACTION_REMOVED,
|
||||||
@@ -50,6 +51,7 @@ export const EventChannels = {
|
|||||||
GUILD_MEMBER_ADDED: DISCORD_GUILD_MEMBER_ADDED,
|
GUILD_MEMBER_ADDED: DISCORD_GUILD_MEMBER_ADDED,
|
||||||
GUILD_MEMBER_REMOVED: DISCORD_GUILD_MEMBER_REMOVED,
|
GUILD_MEMBER_REMOVED: DISCORD_GUILD_MEMBER_REMOVED,
|
||||||
VOICE_ANALYZED: DISCORD_VOICE_ANALYZED,
|
VOICE_ANALYZED: DISCORD_VOICE_ANALYZED,
|
||||||
|
MODERATION_ACTION: DISCORD_MODERATION_ACTION,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type EventChannelType =
|
export type EventChannelType =
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ import type * as schema from "../../shared/database/schema.js";
|
|||||||
import { moderationActionsTable } from "../../shared/database/schema.js";
|
import { moderationActionsTable } from "../../shared/database/schema.js";
|
||||||
import { buildCursorCondition, pageResult } from "../../shared/index.js";
|
import { buildCursorCondition, pageResult } from "../../shared/index.js";
|
||||||
import { createChildLogger, type Logger } from "../../shared/logger/index.js";
|
import { createChildLogger, type Logger } from "../../shared/logger/index.js";
|
||||||
|
import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js";
|
||||||
import type { ModerationAction, PageResult } from "../message-capture/types.js";
|
import type { ModerationAction, PageResult } from "../message-capture/types.js";
|
||||||
|
|
||||||
|
let _eventBroadcaster: EventBroadcaster | null = null;
|
||||||
|
|
||||||
|
/** Inject the gateway's event broadcaster so actions can be published live. */
|
||||||
|
export function setModerationEventBroadcaster(eb: EventBroadcaster): void {
|
||||||
|
_eventBroadcaster = eb;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── ModerationActionsDb Class ──────────────────────────────────────────────
|
// ─── ModerationActionsDb Class ──────────────────────────────────────────────
|
||||||
|
|
||||||
export class ModerationActionsDb {
|
export class ModerationActionsDb {
|
||||||
@@ -38,7 +46,16 @@ export class ModerationActionsDb {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return rows[0] as ModerationAction;
|
const created = rows[0] as ModerationAction;
|
||||||
|
|
||||||
|
// Fire-and-forget live broadcast (backend WS → frontend feed).
|
||||||
|
if (_eventBroadcaster) {
|
||||||
|
_eventBroadcaster
|
||||||
|
.moderationAction(created as unknown as Record<string, unknown>)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const DISCORD_CHANNEL_TOPIC_UPDATED = "discord:channel:topic_updated";
|
|||||||
export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated";
|
export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated";
|
||||||
export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added";
|
export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added";
|
||||||
export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed";
|
export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed";
|
||||||
|
export const DISCORD_MODERATION_ACTION = "discord:moderation:action";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Command channels (backend -> discord-gateway)
|
// Command channels (backend -> discord-gateway)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||||
import {
|
import {
|
||||||
useLoadMore,
|
useLoadMore,
|
||||||
|
useMessageActivity,
|
||||||
useMessageDetail,
|
useMessageDetail,
|
||||||
useMessageSearch,
|
useMessageSearch,
|
||||||
useMessages,
|
useMessages,
|
||||||
@@ -71,6 +74,8 @@ export function MessagesView({
|
|||||||
// Search mode: "exact" (substring match over captured messages) or
|
// Search mode: "exact" (substring match over captured messages) or
|
||||||
// "semantic" (vector similarity over the persistent Qdrant archive).
|
// "semantic" (vector similarity over the persistent Qdrant archive).
|
||||||
const [semanticMode, setSemanticMode] = useState(false);
|
const [semanticMode, setSemanticMode] = useState(false);
|
||||||
|
// feed | timeline: "timeline" groups messages into date-grouped cards.
|
||||||
|
const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed");
|
||||||
// Guard against loading the entire history on a long scroll: cap how many
|
// Guard against loading the entire history on a long scroll: cap how many
|
||||||
// older pages we append. Each page is 50 messages (backend limit default).
|
// older pages we append. Each page is 50 messages (backend limit default).
|
||||||
const MAX_OLDER_PAGES = 10;
|
const MAX_OLDER_PAGES = 10;
|
||||||
@@ -106,6 +111,7 @@ export function MessagesView({
|
|||||||
query,
|
query,
|
||||||
query.trim().length >= 2 && semanticMode,
|
query.trim().length >= 2 && semanticMode,
|
||||||
);
|
);
|
||||||
|
const activity = useMessageActivity(30);
|
||||||
const detail = useMessageDetail(selected);
|
const detail = useMessageDetail(selected);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
|
|
||||||
@@ -140,6 +146,33 @@ export function MessagesView({
|
|||||||
// returns DESC (newest first); reverse so the feed reads top→bottom like DC.
|
// returns DESC (newest first); reverse so the feed reads top→bottom like DC.
|
||||||
const display = useMemo(() => [...list].reverse(), [list]);
|
const display = useMemo(() => [...list].reverse(), [list]);
|
||||||
|
|
||||||
|
// Timeline mode: inject date-separator headers above the first message of
|
||||||
|
// each day. Messages are sorted oldest→newest (display is reversed), so a
|
||||||
|
// date change means a new group. Produces an array of either "date" or "msg"
|
||||||
|
// nodes so the render loop can switch easily.
|
||||||
|
const timelineNodes = useMemo(() => {
|
||||||
|
if (viewMode !== "timeline") return null;
|
||||||
|
const out: Array<
|
||||||
|
| { type: "date"; label: string; iso: string }
|
||||||
|
| { type: "msg"; m: (typeof display)[number] }
|
||||||
|
> = [];
|
||||||
|
let prev = "";
|
||||||
|
for (const m of display) {
|
||||||
|
const d = new Date(m.created_at).toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
const iso = new Date(m.created_at).toISOString().slice(0, 10);
|
||||||
|
if (d !== prev) {
|
||||||
|
out.push({ type: "date", label: d, iso });
|
||||||
|
prev = d;
|
||||||
|
}
|
||||||
|
out.push({ type: "msg", m });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [display, viewMode]);
|
||||||
|
|
||||||
// Ref to the scroll container so we can manage scroll position like Discord:
|
// Ref to the scroll container so we can manage scroll position like Discord:
|
||||||
// open at the bottom (newest), keep the viewport stable when prepending older
|
// open at the bottom (newest), keep the viewport stable when prepending older
|
||||||
// messages at the top, and follow new live messages only when already near
|
// messages at the top, and follow new live messages only when already near
|
||||||
@@ -208,6 +241,20 @@ export function MessagesView({
|
|||||||
>
|
>
|
||||||
{semanticMode ? "Semantic" : "Exact"}
|
{semanticMode ? "Semantic" : "Exact"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||||
|
viewMode === "timeline"
|
||||||
|
? "border-signal/40 bg-signal/10 text-signal"
|
||||||
|
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||||
|
}`}
|
||||||
|
title="Toggle timeline (date-grouped) view"
|
||||||
|
>
|
||||||
|
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
||||||
|
</button>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-5">
|
<div className="grid gap-4 lg:grid-cols-5">
|
||||||
@@ -326,44 +373,32 @@ export function MessagesView({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{display.map((m, i) => (
|
{viewMode === "timeline" && timelineNodes
|
||||||
<button
|
? timelineNodes.map((node, _i) =>
|
||||||
key={m.id}
|
node.type === "date" ? (
|
||||||
type="button"
|
<div
|
||||||
onClick={() => setSelected(m.id)}
|
key={`date-${node.iso}`}
|
||||||
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
className="flex items-center gap-2 px-1 text-[0.65rem] text-ink-faint"
|
||||||
selected === m.id
|
|
||||||
? "border-signal/40 bg-signal/8"
|
|
||||||
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
|
||||||
}`}
|
|
||||||
style={staggerDelay(i)}
|
|
||||||
>
|
>
|
||||||
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
<Calendar className="size-3" />
|
||||||
<div className="min-w-0 flex-1">
|
{node.label}
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="truncate text-sm font-semibold text-ink">
|
|
||||||
{m.username}
|
|
||||||
</span>
|
|
||||||
<span className="mono text-[0.65rem] text-ink-faint">
|
|
||||||
{getMessageChannelLabel(m)}
|
|
||||||
</span>
|
|
||||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
|
||||||
{formatRelativeTime(m.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
) : (
|
||||||
{renderMessageContent(m.content, m.metadata) || (
|
<MessageRow
|
||||||
<span className="italic text-ink-faint">
|
key={node.m.id}
|
||||||
(empty / embed)
|
m={node.m}
|
||||||
</span>
|
selected={selected}
|
||||||
)}
|
onSelect={setSelected}
|
||||||
</div>
|
/>
|
||||||
</div>
|
),
|
||||||
<AiBadge
|
)
|
||||||
status={m.ai_status}
|
: display.map((m, _i) => (
|
||||||
durationMs={m.ai_analysis_duration_ms}
|
<MessageRow
|
||||||
|
key={m.id}
|
||||||
|
m={m}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={setSelected}
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,6 +427,10 @@ export function MessagesView({
|
|||||||
)}
|
)}
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activity.data && activity.data.length > 0 && (
|
||||||
|
<ActivityHeatmap buckets={activity.data} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -513,3 +552,48 @@ function MessageDetail({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single message card used by both the live feed and the date-grouped timeline. */
|
||||||
|
function MessageRow({
|
||||||
|
m,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
m: MessageRecord;
|
||||||
|
selected: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(m.id)}
|
||||||
|
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||||
|
selected === m.id
|
||||||
|
? "border-signal/40 bg-signal/8"
|
||||||
|
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-semibold text-ink">
|
||||||
|
{m.username}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
|
{getMessageChannelLabel(m)}
|
||||||
|
</span>
|
||||||
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||||
|
{formatRelativeTime(m.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
||||||
|
{renderMessageContent(m.content, m.metadata) || (
|
||||||
|
<span className="italic text-ink-faint">(empty / embed)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { Donut } from "@/components/charts";
|
import { Donut } from "@/components/charts";
|
||||||
|
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
GlassPanel,
|
GlassPanel,
|
||||||
@@ -30,8 +31,15 @@ import {
|
|||||||
SkeletonPanel,
|
SkeletonPanel,
|
||||||
SkeletonRows,
|
SkeletonRows,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { useModerationActions, useModerationStats } from "@/hooks";
|
import { TopicTrends } from "@/components/TopicTrends";
|
||||||
|
import {
|
||||||
|
useLiveModeration,
|
||||||
|
useModerationActions,
|
||||||
|
useModerationStats,
|
||||||
|
useModerationTrends,
|
||||||
|
} from "@/hooks";
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
|
import { downloadCsv } from "@/lib/csv";
|
||||||
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
||||||
import type {
|
import type {
|
||||||
ModerationAction,
|
ModerationAction,
|
||||||
@@ -71,6 +79,8 @@ export function ModerationView({
|
|||||||
typeFilter || undefined,
|
typeFilter || undefined,
|
||||||
!statusFilter && !typeFilter ? initialActions : undefined,
|
!statusFilter && !typeFilter ? initialActions : undefined,
|
||||||
);
|
);
|
||||||
|
const liveActions = useLiveModeration(initialActions ?? [], 50);
|
||||||
|
const { data: trends } = useModerationTrends(30);
|
||||||
|
|
||||||
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
||||||
|
|
||||||
@@ -150,6 +160,18 @@ export function ModerationView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-5">
|
<div className="grid gap-5 lg:grid-cols-5">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{trends ? (
|
||||||
|
<TopicTrends trends={trends} />
|
||||||
|
) : (
|
||||||
|
<SkeletonPanel rows={6} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-5">
|
||||||
|
<LiveModerationFeed actions={liveActions} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<GlassPanel className="lg:col-span-2">
|
<GlassPanel className="lg:col-span-2">
|
||||||
<SectionHeader eyebrow="health" title="Breakdown" />
|
<SectionHeader eyebrow="health" title="Breakdown" />
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
@@ -215,6 +237,30 @@ export function ModerationView({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="w-32"
|
className="w-32"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
downloadCsv(
|
||||||
|
"moderation-actions.csv",
|
||||||
|
(actions ?? []).map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
user: a.username ?? a.user_id,
|
||||||
|
action_type: a.action_type,
|
||||||
|
status: a.status,
|
||||||
|
severity: a.severity ?? "",
|
||||||
|
categories: (a.categories ?? []).join("|"),
|
||||||
|
reason: a.reason ?? "",
|
||||||
|
created_at: a.created_at
|
||||||
|
? new Date(a.created_at).toISOString()
|
||||||
|
: "",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]"
|
||||||
|
title="Download moderation actions as CSV"
|
||||||
|
>
|
||||||
|
CSV
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GlassPanel } from "@/components/primitives";
|
||||||
|
import { SectionHeader } from "@/components/shared";
|
||||||
|
import type { MessageActivityBucket } from "@/lib/types";
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
|
||||||
|
function heatColor(t: number): string {
|
||||||
|
// t in [0,1] → signal gradient (dark → bright).
|
||||||
|
if (t <= 0) return "var(--color-hairline)";
|
||||||
|
return `rgba(45, 212, 191, ${0.15 + 0.85 * t})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityHeatmap({
|
||||||
|
buckets,
|
||||||
|
}: {
|
||||||
|
buckets: MessageActivityBucket[];
|
||||||
|
}) {
|
||||||
|
// Group by channel, find max count for normalization.
|
||||||
|
const channels = Array.from(new Set(buckets.map((b) => b.channelId)));
|
||||||
|
const byKey = new Map<string, number>();
|
||||||
|
let max = 0;
|
||||||
|
for (const b of buckets) {
|
||||||
|
const k = `${b.channelId}:${b.hour}`;
|
||||||
|
byKey.set(k, (byKey.get(k) ?? 0) + b.count);
|
||||||
|
if (byKey.get(k)! > max) max = byKey.get(k)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buckets.length === 0) {
|
||||||
|
return (
|
||||||
|
<GlassPanel className="lg:col-span-2">
|
||||||
|
<SectionHeader eyebrow="insight" title="Activity Heatmap" />
|
||||||
|
<p className="py-6 text-center text-xs text-ink-faint">
|
||||||
|
No message activity recorded yet.
|
||||||
|
</p>
|
||||||
|
</GlassPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassPanel className="lg:col-span-5">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="insight"
|
||||||
|
title="Activity Heatmap"
|
||||||
|
action={
|
||||||
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
|
{channels.length} channels · messages/hour
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[640px] space-y-1">
|
||||||
|
{channels.map((ch) => (
|
||||||
|
<div key={ch} className="flex items-center gap-2">
|
||||||
|
<span className="mono w-24 shrink-0 truncate text-[0.6rem] text-ink-faint">
|
||||||
|
{ch.slice(-6)}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-1 gap-0.5">
|
||||||
|
{HOURS.map((h) => {
|
||||||
|
const c = byKey.get(`${ch}:${h}`) ?? 0;
|
||||||
|
const t = max > 0 ? c / max : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={h}
|
||||||
|
title={`${ch} · ${String(h).padStart(2, "0")}:00 — ${c} msgs`}
|
||||||
|
className="h-4 flex-1 rounded-[2px]"
|
||||||
|
style={{ background: heatColor(t) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<span className="w-24 shrink-0" />
|
||||||
|
<div className="flex flex-1 justify-between">
|
||||||
|
{[0, 6, 12, 18, 23].map((h) => (
|
||||||
|
<span key={h} className="mono text-[0.55rem] text-ink-faint">
|
||||||
|
{String(h).padStart(2, "0")}h
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge, GlassPanel } from "@/components/primitives";
|
||||||
|
import { formatRelativeTime } from "@/lib/format";
|
||||||
|
import type { ModerationAction } from "@/lib/types";
|
||||||
|
|
||||||
|
const ACTION_LABEL: Record<string, string> = {
|
||||||
|
delete_message: "Deleted",
|
||||||
|
timeout_user: "Timeout",
|
||||||
|
warn_user: "Warned",
|
||||||
|
reset_nickname: "Nickname reset",
|
||||||
|
ban_user: "Banned",
|
||||||
|
kick_user: "Kicked",
|
||||||
|
notify_user: "Notified",
|
||||||
|
none: "None",
|
||||||
|
};
|
||||||
|
|
||||||
|
function severityTone(
|
||||||
|
sev?: string | null,
|
||||||
|
): "signal" | "amber" | "vermilion" | null {
|
||||||
|
switch (sev) {
|
||||||
|
case "critical":
|
||||||
|
case "high":
|
||||||
|
return "vermilion";
|
||||||
|
case "medium":
|
||||||
|
return "amber";
|
||||||
|
case "low":
|
||||||
|
return "signal";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveModerationFeed({
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
actions: ModerationAction[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<GlassPanel className="flex max-h-[420px] flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="relative flex size-2.5">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex size-2.5 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
<h3 className="text-sm font-medium text-ink">Live Feed</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-ink-faint">{actions.length} recent</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{actions.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-xs text-ink-faint">
|
||||||
|
Waiting for new moderation actions…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-white/5">
|
||||||
|
{actions.map((a, i) => {
|
||||||
|
const tone = severityTone(a.severity);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className={`flex items-start gap-3 px-4 py-3 ${
|
||||||
|
i === 0 ? "animate-[fadeIn_0.4s_ease-out]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge tone={tone ?? "signal"} className="capitalize">
|
||||||
|
{ACTION_LABEL[a.action_type] ?? a.action_type}
|
||||||
|
</Badge>
|
||||||
|
{a.severity && (
|
||||||
|
<span className="text-xs text-ink-faint">
|
||||||
|
{a.severity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{a.categories?.length ? (
|
||||||
|
<span className="truncate text-xs text-ink-soft">
|
||||||
|
{a.categories.slice(0, 3).join(", ")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{a.reason && (
|
||||||
|
<p className="mt-1 truncate text-xs text-ink-soft">
|
||||||
|
“{a.reason}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-0.5 text-[11px] text-ink-faint">
|
||||||
|
{a.username ?? a.user_id ?? "unknown"} ·{" "}
|
||||||
|
{formatRelativeTime(a.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Donut } from "@/components/charts/donut";
|
||||||
|
import { GlassPanel } from "@/components/primitives";
|
||||||
|
import { SectionHeader } from "@/components/shared";
|
||||||
|
import { formatNumber } from "@/lib/format";
|
||||||
|
import type { ModerationTrends } from "@/lib/types";
|
||||||
|
|
||||||
|
const SEVERITY_COLOR: Record<string, string> = {
|
||||||
|
critical: "var(--color-vermilion)",
|
||||||
|
high: "var(--color-vermilion)",
|
||||||
|
medium: "var(--color-amber)",
|
||||||
|
low: "var(--color-signal)",
|
||||||
|
none: "var(--color-ink-faint)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function BarRow({
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
max,
|
||||||
|
color = "var(--color-signal)",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
max: number;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
const pct = max > 0 ? Math.max(2, (count / max) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="w-32 shrink-0 truncate text-ink-soft">{label}</span>
|
||||||
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{ width: `${pct}%`, background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="mono w-10 shrink-0 text-right text-ink">
|
||||||
|
{formatNumber(count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopicTrends({ trends }: { trends: ModerationTrends }) {
|
||||||
|
const maxCat = trends.categories.reduce((m, c) => Math.max(m, c.count), 0);
|
||||||
|
const maxAct = trends.actions.reduce((m, a) => Math.max(m, a.count), 0);
|
||||||
|
const totalSev = trends.severities.reduce((s, x) => s + x.count, 0);
|
||||||
|
|
||||||
|
const severitySegments = trends.severities.map((s) => ({
|
||||||
|
value: s.count,
|
||||||
|
color: SEVERITY_COLOR[s.level] ?? "var(--color-ink-faint)",
|
||||||
|
label: s.level,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassPanel className="lg:col-span-2">
|
||||||
|
<SectionHeader eyebrow="insight" title="Toxic Topic Trends" />
|
||||||
|
{trends.categories.length === 0 && trends.severities.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-xs text-ink-faint">
|
||||||
|
No categorized actions in the last 30 days.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs uppercase tracking-wide text-ink-faint">
|
||||||
|
Top flagged categories
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{trends.categories.slice(0, 10).map((c) => (
|
||||||
|
<BarRow
|
||||||
|
key={c.name}
|
||||||
|
label={c.name}
|
||||||
|
count={c.count}
|
||||||
|
max={maxCat}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{trends.categories.length === 0 && (
|
||||||
|
<p className="text-xs text-ink-faint">
|
||||||
|
No categories recorded.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs uppercase tracking-wide text-ink-faint">
|
||||||
|
Severity
|
||||||
|
</p>
|
||||||
|
{totalSev > 0 ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Donut
|
||||||
|
segments={severitySegments}
|
||||||
|
centerLabel={formatNumber(totalSev)}
|
||||||
|
centerSub="total"
|
||||||
|
size={88}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{trends.severities.map((s) => (
|
||||||
|
<div key={s.level} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="size-2.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
SEVERITY_COLOR[s.level] ??
|
||||||
|
"var(--color-ink-faint)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="capitalize text-ink-soft">
|
||||||
|
{s.level}
|
||||||
|
</span>
|
||||||
|
<span className="mono ml-auto text-ink">
|
||||||
|
{formatNumber(s.count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-ink-faint">No severity data.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs uppercase tracking-wide text-ink-faint">
|
||||||
|
Action types
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{trends.actions.slice(0, 6).map((a) => (
|
||||||
|
<BarRow
|
||||||
|
key={a.type}
|
||||||
|
label={a.type.replace("_", " ")}
|
||||||
|
count={a.count}
|
||||||
|
max={maxAct}
|
||||||
|
color="#8b5cf6"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,10 +30,13 @@ export {
|
|||||||
useReview,
|
useReview,
|
||||||
useSemanticSearch,
|
useSemanticSearch,
|
||||||
useTextChannels,
|
useTextChannels,
|
||||||
|
useMessageActivity,
|
||||||
} from "./use-messages";
|
} from "./use-messages";
|
||||||
export {
|
export {
|
||||||
|
useLiveModeration,
|
||||||
useModerationActions,
|
useModerationActions,
|
||||||
useModerationStats,
|
useModerationStats,
|
||||||
|
useModerationTrends,
|
||||||
} from "./use-moderation";
|
} from "./use-moderation";
|
||||||
export {
|
export {
|
||||||
useDeleteRecording,
|
useDeleteRecording,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { messagesApi, voiceApi } from "@/lib/api";
|
|||||||
import type {
|
import type {
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
Channel,
|
Channel,
|
||||||
|
MessageActivityBucket,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
SemanticSearchResult,
|
SemanticSearchResult,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
@@ -374,3 +375,9 @@ export function useMessagesStream(
|
|||||||
|
|
||||||
return { streaming, error };
|
return { streaming, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMessageActivity(days = 30) {
|
||||||
|
return useSWR<MessageActivityBucket[]>(["activity", days], () =>
|
||||||
|
messagesApi.getActivity(days),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { moderationApi } from "@/lib/api";
|
import { moderationApi } from "@/lib/api";
|
||||||
import type { ModerationAction, ModerationStats } from "@/lib/types";
|
import type {
|
||||||
|
ModerationAction,
|
||||||
|
ModerationStats,
|
||||||
|
ModerationTrends,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
export function useModerationStats(initialData?: ModerationStats) {
|
export function useModerationStats(initialData?: ModerationStats) {
|
||||||
return useSWR<ModerationStats>(
|
return useSWR<ModerationStats>(
|
||||||
@@ -33,3 +39,46 @@ export function useModerationActions(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live moderation feed: merges the initial SWR list with actions pushed over
|
||||||
|
* the WebSocket in real time. Returns a capped, newest-first buffer.
|
||||||
|
* Read-only / public — no write actions.
|
||||||
|
*/
|
||||||
|
export function useLiveModeration(
|
||||||
|
initialData: ModerationAction[] = [],
|
||||||
|
cap = 50,
|
||||||
|
) {
|
||||||
|
const { on: subscribe } = useWebSocket();
|
||||||
|
const [live, setLive] = useState<ModerationAction[]>(initialData);
|
||||||
|
const seen = useRef<Set<string>>(new Set(initialData.map((a) => a.id)));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLive(initialData);
|
||||||
|
seen.current = new Set(initialData.map((a) => a.id));
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
const handle = useCallback(
|
||||||
|
(action: ModerationAction) => {
|
||||||
|
if (seen.current.has(action.id)) return;
|
||||||
|
seen.current.add(action.id);
|
||||||
|
setLive((prev) => [action, ...prev].slice(0, cap));
|
||||||
|
},
|
||||||
|
[cap],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe("moderation_action", handle);
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe, handle]);
|
||||||
|
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModerationTrends(days = 30, initialData?: ModerationTrends) {
|
||||||
|
return useSWR<ModerationTrends>(
|
||||||
|
["moderation-trends", days],
|
||||||
|
() => moderationApi.getTrends(days),
|
||||||
|
{ fallbackData: initialData },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import type {
|
import type {
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
|
MessageActivityBucket,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
SemanticSearchResult,
|
SemanticSearchResult,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
@@ -73,4 +74,10 @@ export const messagesApi = {
|
|||||||
results: SemanticSearchResult[];
|
results: SemanticSearchResult[];
|
||||||
nextCursor: null;
|
nextCursor: null;
|
||||||
}>,
|
}>,
|
||||||
|
|
||||||
|
// Public, read-only activity heatmap data (per-hour volume by channel).
|
||||||
|
getActivity: (days = 30) =>
|
||||||
|
orpc.messages.activity({ days }) as unknown as Promise<
|
||||||
|
MessageActivityBucket[]
|
||||||
|
>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import type { ModerationStats, PaginatedModerationActions } from "@/lib/types";
|
import type {
|
||||||
|
ModerationStats,
|
||||||
|
ModerationTrends,
|
||||||
|
PaginatedModerationActions,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
export const moderationApi = {
|
export const moderationApi = {
|
||||||
getStats: () =>
|
getStats: () =>
|
||||||
@@ -17,4 +21,7 @@ export const moderationApi = {
|
|||||||
actionType,
|
actionType,
|
||||||
cursor,
|
cursor,
|
||||||
}) as unknown as Promise<PaginatedModerationActions>,
|
}) as unknown as Promise<PaginatedModerationActions>,
|
||||||
|
|
||||||
|
getTrends: (days = 30) =>
|
||||||
|
orpc.moderation.trends({ days }) as unknown as Promise<ModerationTrends>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/** Client-side CSV export. Pure browser — no backend, no write scope. */
|
||||||
|
export function toCsv(rows: Record<string, unknown>[]): string {
|
||||||
|
if (rows.length === 0) return "";
|
||||||
|
const headers = Array.from(
|
||||||
|
rows.reduce<Set<string>>((s, r) => {
|
||||||
|
Object.keys(r).forEach((k) => s.add(k));
|
||||||
|
return s;
|
||||||
|
}, new Set()),
|
||||||
|
);
|
||||||
|
const esc = (v: unknown): string => {
|
||||||
|
if (v == null) return "";
|
||||||
|
const s = typeof v === "object" ? JSON.stringify(v) : String(v);
|
||||||
|
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
|
};
|
||||||
|
const head = headers.map(esc).join(",");
|
||||||
|
const body = rows
|
||||||
|
.map((r) => headers.map((h) => esc(r[h])).join(","))
|
||||||
|
.join("\n");
|
||||||
|
return `${head}\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadCsv(filename: string, rows: Record<string, unknown>[]) {
|
||||||
|
const csv = toCsv(rows);
|
||||||
|
if (!csv) return;
|
||||||
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -173,6 +173,12 @@ export interface SemanticSearchResult {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageActivityBucket {
|
||||||
|
channelId: string;
|
||||||
|
hour: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SemanticSearchResponse {
|
export interface SemanticSearchResponse {
|
||||||
results: SemanticSearchResult[];
|
results: SemanticSearchResult[];
|
||||||
nextCursor: null;
|
nextCursor: null;
|
||||||
|
|||||||
@@ -44,3 +44,9 @@ export interface PaginatedModerationActions {
|
|||||||
data: ModerationAction[];
|
data: ModerationAction[];
|
||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModerationTrends {
|
||||||
|
categories: { name: string; count: number }[];
|
||||||
|
severities: { level: string; count: number }[];
|
||||||
|
actions: { type: string; count: number }[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
ActiveSpeaker,
|
ActiveSpeaker,
|
||||||
MediaState,
|
MediaState,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
|
ModerationAction,
|
||||||
VoiceRecording,
|
VoiceRecording,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -68,6 +69,8 @@ export interface WsEventMap {
|
|||||||
presence_updated: unknown;
|
presence_updated: unknown;
|
||||||
guild_member_added: unknown;
|
guild_member_added: unknown;
|
||||||
guild_member_removed: unknown;
|
guild_member_removed: unknown;
|
||||||
|
/** Live moderation action broadcast (gateway → Redis → backend → WS). */
|
||||||
|
moderation_action: ModerationAction;
|
||||||
media_state: MediaState;
|
media_state: MediaState;
|
||||||
user_state: unknown;
|
user_state: unknown;
|
||||||
ui_state: unknown;
|
ui_state: unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user