refactor: comprehensive codebase cleanup and architecture hardening

- Sprint 1 (Quick Wins): Remove dead analytics modules, fix 4 unresolved
  imports, replace 3 console.warn with logger, remove mock-crc import
- Sprint 2 (Architecture): Create MascotChatRepository, AnalysisRepository,
  3 Zod schemas (mascot-chat, analysis, voice), deduplicate error classes,
  move 3 SQL queries from routes to repository
- Sprint 3 (Complexity): Replace 7 any types with proper interfaces,
  extract 6 helpers from prepareMediaMessage (CC 85 -> ~15)
- Sprint 4 (Config): Remove 22 dead env vars from .env, add 30 missing
  vars to .env.example, standardize naming

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-09 10:16:04 +07:00
co-authored by Claude Opus 4.8
parent d0d9e1669e
commit 4becf0d6f1
89 changed files with 1260 additions and 5499 deletions
@@ -1,3 +1,4 @@
import { ConfigError, DatabaseError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { Client } from "discord.js-selfbot-v13";
import { startPendingAIAnalysisWorker } from "../modules/ai-moderation/aiAnalyzer.js";
@@ -19,7 +20,6 @@ import {
} from "../shared/database/drizzle.js";
import { runMigrations } from "../shared/database/migrate.js";
import { createDiscordClientOptions } from "../shared/discord/clientOptions.js";
import { ConfigError, DatabaseError } from "../shared/errors/errors.js";
import { createGracefulShutdown } from "./shutdown.js";
const logger = createChildLogger("discord-gateway");
-1
View File
@@ -1,4 +1,3 @@
import "./mock-crc.js";
import "libsodium-wrappers";
import "@snazzah/davey";
import "dotenv/config";
@@ -7,7 +7,6 @@ import { LRUCache } from "lru-cache";
import { Piscina } from "piscina";
import { config } from "../../shared/config/config.js";
import type { EventBroadcaster } from "../event-broadcaster/index.js";
import { invalidateAnalyticsCache } from "../message-capture/analyticsStore.js";
import { isAgeRestrictedMetadata } from "../message-capture/messageMetadata.js";
import {
getConversationKeysWithIncompleteAnalysis,
@@ -500,7 +499,6 @@ async function processIndividualFallback(
const rows = await updateMessagesAIAnalysisBulk(updates);
for (const row of rows) {
broadcastAnalysisCompleted(row);
invalidateAnalyticsCache(row.guild_id);
scheduleAutoDelete(row);
// Update reputation autonomously (Belajar & Kebijaksanaan)
@@ -1,8 +1,8 @@
import { eq } from "drizzle-orm";
import { getDatabase } from "../../shared/database/drizzle.js";
import {
channelCulturesTable,
ChannelCulture,
channelCulturesTable,
} from "../../shared/database/schema.js";
/**
@@ -1,13 +1,13 @@
import { eq, desc, sql, and } from "drizzle-orm";
import { createChildLogger } from "@bete/shared/logger";
import { and, desc, eq, sql } from "drizzle-orm";
import { config } from "../../shared/config/config.js";
import { getDatabase } from "../../shared/database/drizzle.js";
import {
messagesTable,
channelCulturesTable,
messagesTable,
} from "../../shared/database/schema.js";
import { config } from "../../shared/config/config.js";
import { createChildLogger } from "@bete/shared/logger";
import { llmChat } from "./llmClient.js";
import { updateChannelCulture } from "./channelCultureStore.js";
import { llmChat } from "./llmClient.js";
const CULTURE_LEARNING_INTERVAL = 1000 * 60 * 60 * 12; // 12 hours
const log = createChildLogger("cultureLearner");
@@ -84,14 +84,14 @@ export async function llmChat(
signal,
} = opts;
const params: any = {
const params = {
model,
messages,
};
...(stream !== undefined ? { stream } : {}),
} as OpenAI.Chat.Completions.ChatCompletionCreateParams;
// Attach optional parameters only if explicitly provided to maintain
// maximum compatibility with various LLM providers and local APIs.
if (stream !== undefined) params.stream = stream;
if (temperature !== undefined) params.temperature = temperature;
if (top_p !== undefined) params.top_p = top_p;
if (max_tokens !== undefined) params.max_tokens = max_tokens;
@@ -103,7 +103,9 @@ export async function llmChat(
return retryWithBackoff(
async () => {
return withLlmConcurrency(async () => {
const execute = async (currentParams: any) => {
const execute = async (
currentParams: OpenAI.Chat.Completions.ChatCompletionCreateParams,
) => {
const response = await client.chat.completions.create(currentParams, {
signal,
});
@@ -161,7 +163,9 @@ export async function llmChat(
{ model },
"Provider rejected non-streaming request. Fallback to stream: true initiated.",
);
params.stream = true;
(
params as unknown as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
).stream = true;
return await execute(params);
}
@@ -11,11 +11,9 @@ import type {
AttachmentRecord,
MessageRecord,
} from "../message-capture/types.js";
import { getChannelCulture } from "./channelCultureStore.js";
import { llmChat, llmVision } from "./llmClient.js";
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
import { initializeUserReputation } from "./userReputationStore.js";
import { getChannelCulture } from "./channelCultureStore.js";
import { logModerationAnalysis, logModerationError } from "./responseLogger.js";
import {
getStickerFromCache,
@@ -46,6 +44,7 @@ import {
upsertCachedMediaByPhash,
} from "./textCacheStore.js";
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
import { initializeUserReputation } from "./userReputationStore.js";
const SeveritySchema = z.enum(["none", "low", "medium", "high", "critical"]);
const RecommendedActionSchema = z.enum([
@@ -170,7 +169,7 @@ function deriveRecommendedAction(
/**
* Helper to extract JSON from a potentially conversational or markdown-wrapped string.
*/
export function extractJson(content: string): any {
export function extractJson(content: string): unknown {
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/g;
const matches = content.matchAll(codeBlockRegex);
for (const match of matches) {
@@ -1046,7 +1045,7 @@ async function runTextOnlyBatch(
if (rawContent.length > 0 && rawContent.length < 20) {
const groupKey = rawContent.toLowerCase();
if (shortContentGroups.has(groupKey)) {
shortContentGroups.get(groupKey)!.push(msg);
shortContentGroups.get(groupKey)?.push(msg);
} else {
shortContentGroups.set(groupKey, [msg]);
deduplicatedTargets.push(msg); // first occurrence = representative
@@ -1278,9 +1277,6 @@ async function prepareMediaMessage(
const webTextMap = new Map<string, string[]>();
const mediaAnalysisMap = new Map<string, string[]>();
const getAttachmentImageUrl = (att: AttachmentRecord): string | null =>
att.uploaded_url ?? att.discord_url ?? null;
const maxDimension = config.AI_LLM_IMAGE_MAX_DIMENSION ?? 1024;
const content = getAnalysisContent(target);
@@ -1292,93 +1288,14 @@ async function prepareMediaMessage(
.filter(
(att) =>
att.message_id === targetId &&
getAttachmentImageUrl(att) &&
(att.uploaded_url ?? att.discord_url ?? null) &&
att.type.startsWith("image/"),
)
.slice(0, 8);
for (const att of msgAttachments) {
downloadPromises.push(
(async () => {
const urlToUse = getAttachmentImageUrl(att);
if (!urlToUse) {
log.warn(
{ attachmentId: att.id, messageId: att.message_id },
"Skipping attachment: no uploaded URL available",
);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(urlToUse, { signal: controller.signal });
if (!res.ok || !res.body) {
log.warn(
{ attachmentId: att.id, url: urlToUse, status: res.status },
"Failed to download attachment: HTTP error or no body",
);
return;
}
let totalBytes = 0;
const chunks: Uint8Array[] = [];
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
totalBytes += value.length;
if (totalBytes > 10 * 1024 * 1024) {
log.warn(
{ attachmentId: att.id, totalBytes },
"Attachment too large (>10MB) — skipping",
);
reader.cancel();
return;
}
chunks.push(value);
}
}
const imageBytes = Buffer.concat(chunks);
const sniffedMime = sniffImageMimeType(imageBytes);
if (!sniffedMime) {
log.warn(
{ attachmentId: att.id },
"Skipping attachment: not a recognised image format",
);
return;
}
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(imageBytes, maxDimension);
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
const part: MessageImagePart = {
type: "image_url",
image_url: { url: dataUrl },
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
};
const existing = imageMap.get(targetId) ?? [];
if (existing.length < 8) {
existing.push(part);
imageMap.set(targetId, existing);
}
} catch (err) {
log.warn(
{
attachmentId: att.id,
error: err instanceof Error ? err.message : String(err),
},
"Error downloading attachment",
);
} finally {
clearTimeout(timeoutId);
}
})(),
downloadSingleAttachment(att, targetId, maxDimension, imageMap),
);
}
@@ -1388,186 +1305,23 @@ async function prepareMediaMessage(
for (const url of urls) {
downloadPromises.push(
(async () => {
const result = await fetchUrlSafely(url);
if (result.type === "image" && result.data && result.mimeType) {
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(result.data, maxDimension);
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
const part: MessageImagePart = {
type: "image_url",
image_url: { url: dataUrl },
sourceLabel: `[gambar di atas berasal dari link ${url} pada pesan id=${targetId}]`,
};
const existing = imageMap.get(targetId) ?? [];
if (existing.length < 8) {
existing.push(part);
imageMap.set(targetId, existing);
}
} else if (result.type === "text" && result.textContent) {
urlWebTexts.push(`[Isi Web dari ${url}]: ${result.textContent}`);
}
})(),
fetchUrlInline(url, targetId, maxDimension, imageMap, urlWebTexts),
);
}
// ── Sticker / embed / custom emoji download promises ──
const mediaEvidence = extractMessageMediaEvidence(target.metadata);
const mediaCandidates: Array<{
messageId: string;
url: string;
label: string;
stickerName?: string;
customEmojiId?: string;
customEmojiName?: string;
}> = [
...mediaEvidence.stickers
.filter((s) => s.url)
.map((s) => ({
messageId: targetId,
url: s.url,
label: `[gambar di atas adalah sticker "${s.name}" dari pesan id=${targetId}]`,
stickerName: s.name,
})),
...mediaEvidence.embeds.flatMap((embed) =>
[
embed.image
? {
messageId: targetId,
url: embed.image,
label: `[gambar di atas berasal dari embed image pada pesan id=${targetId}]`,
}
: null,
embed.thumbnail
? {
messageId: targetId,
url: embed.thumbnail,
label: `[gambar di atas berasal dari embed thumbnail pada pesan id=${targetId}]`,
}
: null,
].filter(
(
c,
): c is {
messageId: string;
url: string;
label: string;
stickerName?: string;
customEmojiId?: string;
customEmojiName?: string;
} => c !== null,
),
),
...mediaEvidence.customEmojis.map((emoji) => ({
messageId: targetId,
url: emoji.url,
label: `[gambar di atas adalah custom emoji "${emoji.name}" dari pesan id=${targetId}]`,
customEmojiId: emoji.id,
customEmojiName: emoji.name,
})),
];
const mediaCandidates = buildMediaCandidates(targetId, mediaEvidence);
for (const candidate of mediaCandidates) {
downloadPromises.push(
(async () => {
if ((imageMap.get(targetId)?.length ?? 0) >= 8) return;
if (candidate.customEmojiId || candidate.stickerName) {
const visionCacheKey = candidate.customEmojiId
? makeCustomEmojiCacheKey(candidate.customEmojiId)
: makeStickerCacheKey(candidate.stickerName!);
const cachedVision = await getCachedMediaAnalysis(visionCacheKey);
if (cachedVision) {
log.debug(
{ cacheKey: visionCacheKey },
"Vision cache HIT for media candidate — skipped download",
);
const analysisText = `[Media analysis for message ${candidate.messageId}] ${candidate.label}: ${cachedVision}`;
const existing = mediaAnalysisMap.get(targetId) ?? [];
existing.push(analysisText);
mediaAnalysisMap.set(targetId, existing);
return;
}
}
if (candidate.stickerName && isStickerCacheReady()) {
try {
const cached = await getStickerFromCache(candidate.stickerName);
if (cached && cached.imageUrl) {
const part: MessageImagePart = {
type: "image_url",
image_url: { url: cached.imageUrl },
sourceLabel: candidate.label,
stickerName: candidate.stickerName,
};
const existing = imageMap.get(targetId) ?? [];
if (existing.length < 8) {
existing.push(part);
imageMap.set(targetId, existing);
}
return;
}
} catch (stickerErr) {
log.warn(
{
stickerName: candidate.stickerName,
error:
stickerErr instanceof Error
? stickerErr.message
: String(stickerErr),
},
"Sticker cache lookup failed — falling through to network fetch",
);
}
}
const result = await fetchUrlSafely(candidate.url);
if (result.type !== "image" || !result.data || !result.mimeType) {
log.warn(
{
url: candidate.url,
resultType: result.type,
resultHasData: !!result.data,
messageId: candidate.messageId,
label: candidate.stickerName
? `sticker:${candidate.stickerName}`
: candidate.customEmojiName
? `emoji:${candidate.customEmojiName}`
: "embed/other",
},
"Media candidate fetch did not return a usable image — skipping",
);
return;
}
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(result.data, maxDimension);
const base64 = resizedBuffer.toString("base64");
if (candidate.stickerName) {
uploadAndCacheSticker(
candidate.stickerName,
resizedBuffer,
resizedMime,
).catch(() => {});
}
const part: MessageImagePart = {
type: "image_url",
image_url: { url: `data:${resizedMime};base64,${base64}` },
sourceLabel: candidate.label,
stickerName: candidate.stickerName,
customEmojiId: candidate.customEmojiId,
customEmojiName: candidate.customEmojiName,
};
const existing = imageMap.get(targetId) ?? [];
if (existing.length < 8) {
existing.push(part);
imageMap.set(targetId, existing);
}
})(),
downloadMediaCandidate(
candidate,
targetId,
maxDimension,
imageMap,
mediaAnalysisMap,
),
);
}
@@ -2132,3 +1886,275 @@ Kategori: spam`;
: [],
};
}
// ---------------------------------------------------------------------------
// Refactored helpers for prepareMediaMessage (extracted to reduce CC)
// ---------------------------------------------------------------------------
async function downloadSingleAttachment(
att: AttachmentRecord,
targetId: string,
maxDimension: number,
imageMap: Map<string, MessageImagePart[]>,
): Promise<void> {
const urlToUse = att.uploaded_url ?? att.discord_url ?? null;
if (!urlToUse) {
log.warn(
{ attachmentId: att.id, messageId: att.message_id },
"Skipping attachment: no uploaded URL available",
);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(urlToUse, { signal: controller.signal });
if (!res.ok || !res.body) {
log.warn(
{ attachmentId: att.id, url: urlToUse, status: res.status },
"Failed to download attachment: HTTP error or no body",
);
return;
}
let totalBytes = 0;
const chunks: Uint8Array[] = [];
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
totalBytes += value.length;
if (totalBytes > 10 * 1024 * 1024) {
log.warn(
{ attachmentId: att.id, totalBytes },
"Attachment too large (>10MB) — skipping",
);
reader.cancel();
return;
}
chunks.push(value);
}
}
const imageBytes = Buffer.concat(chunks);
const sniffedMime = sniffImageMimeType(imageBytes);
if (!sniffedMime) {
log.warn(
{ attachmentId: att.id },
"Skipping attachment: not a recognised image format",
);
return;
}
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(imageBytes, maxDimension);
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
const part: MessageImagePart = {
type: "image_url",
image_url: { url: dataUrl },
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
};
addImageToMap(imageMap, targetId, part);
} catch (err) {
log.warn(
{
attachmentId: att.id,
error: err instanceof Error ? err.message : String(err),
},
"Error downloading attachment",
);
} finally {
clearTimeout(timeoutId);
}
}
async function downloadMediaCandidate(
candidate: MediaCandidate,
targetId: string,
maxDimension: number,
imageMap: Map<string, MessageImagePart[]>,
mediaAnalysisMap: Map<string, string[]>,
): Promise<void> {
if ((imageMap.get(targetId)?.length ?? 0) >= 8) return;
if (candidate.customEmojiId || candidate.stickerName) {
const visionCacheKey = candidate.customEmojiId
? makeCustomEmojiCacheKey(candidate.customEmojiId)
: makeStickerCacheKey(candidate.stickerName!);
const cachedVision = await getCachedMediaAnalysis(visionCacheKey);
if (cachedVision) {
log.debug(
{ cacheKey: visionCacheKey },
"Vision cache HIT for media candidate — skipped download",
);
const analysisText = `[Media analysis for message ${candidate.messageId}] ${candidate.label}: ${cachedVision}`;
const existing = mediaAnalysisMap.get(targetId) ?? [];
existing.push(analysisText);
mediaAnalysisMap.set(targetId, existing);
return;
}
}
if (candidate.stickerName && isStickerCacheReady()) {
try {
const cached = await getStickerFromCache(candidate.stickerName);
if (cached && cached.imageUrl) {
const part: MessageImagePart = {
type: "image_url",
image_url: { url: cached.imageUrl },
sourceLabel: candidate.label,
stickerName: candidate.stickerName,
};
addImageToMap(imageMap, targetId, part);
return;
}
} catch (stickerErr) {
log.warn(
{
stickerName: candidate.stickerName,
error:
stickerErr instanceof Error
? stickerErr.message
: String(stickerErr),
},
"Sticker cache lookup failed — falling through to network fetch",
);
}
}
const result = await fetchUrlSafely(candidate.url);
if (result.type !== "image" || !result.data || !result.mimeType) {
log.warn(
{
url: candidate.url,
resultType: result.type,
resultHasData: !!result.data,
messageId: candidate.messageId,
label: candidate.stickerName
? `sticker:${candidate.stickerName}`
: candidate.customEmojiName
? `emoji:${candidate.customEmojiName}`
: "embed/other",
},
"Media candidate fetch did not return a usable image — skipping",
);
return;
}
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(result.data, maxDimension);
const base64 = resizedBuffer.toString("base64");
if (candidate.stickerName) {
uploadAndCacheSticker(
candidate.stickerName,
resizedBuffer,
resizedMime,
).catch(() => {});
}
const part: MessageImagePart = {
type: "image_url",
image_url: { url: `data:${resizedMime};base64,${base64}` },
sourceLabel: candidate.label,
stickerName: candidate.stickerName,
customEmojiId: candidate.customEmojiId,
customEmojiName: candidate.customEmojiName,
};
addImageToMap(imageMap, targetId, part);
}
async function fetchUrlInline(
url: string,
targetId: string,
maxDimension: number,
imageMap: Map<string, MessageImagePart[]>,
urlWebTexts: string[],
): Promise<void> {
const result = await fetchUrlSafely(url);
if (result.type === "image" && result.data && result.mimeType) {
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(result.data, maxDimension);
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
const part: MessageImagePart = {
type: "image_url",
image_url: { url: dataUrl },
sourceLabel: `[gambar di atas berasal dari link ${url} pada pesan id=${targetId}]`,
};
addImageToMap(imageMap, targetId, part);
} else if (result.type === "text" && result.textContent) {
urlWebTexts.push(`[Isi Web dari ${url}]: ${result.textContent}`);
}
}
function addImageToMap(
imageMap: Map<string, MessageImagePart[]>,
targetId: string,
part: MessageImagePart,
): void {
const existing = imageMap.get(targetId) ?? [];
if (existing.length < 8) {
existing.push(part);
imageMap.set(targetId, existing);
}
}
interface MediaCandidate {
messageId: string;
url: string;
label: string;
stickerName?: string;
customEmojiId?: string;
customEmojiName?: string;
}
function buildMediaCandidates(
targetId: string,
mediaEvidence: ReturnType<typeof extractMessageMediaEvidence>,
): MediaCandidate[] {
return [
...mediaEvidence.stickers
.filter((s) => s.url)
.map(
(s): MediaCandidate => ({
messageId: targetId,
url: s.url,
label: `[gambar di atas adalah sticker "${s.name}" dari pesan id=${targetId}]`,
stickerName: s.name,
}),
),
...mediaEvidence.embeds.flatMap((embed): MediaCandidate[] =>
[
embed.image
? ({
messageId: targetId,
url: embed.image,
label: `[gambar di atas berasal dari embed image pada pesan id=${targetId}]`,
} as MediaCandidate)
: null,
embed.thumbnail
? ({
messageId: targetId,
url: embed.thumbnail,
label: `[gambar di atas berasal dari embed thumbnail pada pesan id=${targetId}]`,
} as MediaCandidate)
: null,
].filter((c): c is MediaCandidate => c !== null),
),
...mediaEvidence.customEmojis.map(
(emoji): MediaCandidate => ({
messageId: targetId,
url: emoji.url,
label: `[gambar di atas adalah custom emoji "${emoji.name}" dari pesan id=${targetId}]`,
customEmojiId: emoji.id,
customEmojiName: emoji.name,
}),
),
];
}
@@ -1,7 +1,7 @@
import { createChildLogger } from "@bete/shared/logger";
import { config } from "../../shared/config/config.js";
import { uploadToTele } from "../attachment-upload/teleUpload.js";
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
import { uploadToTele } from "../attachment-upload/teleUpload.js";
const logger = createChildLogger("sticker-cache");
@@ -1,9 +1,9 @@
import { eq, and, desc } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { getDatabase } from "../../shared/database/drizzle.js";
import {
userReputationsTable,
messagesTable,
UserReputation,
userReputationsTable,
} from "../../shared/database/schema.js";
/**
@@ -377,7 +377,9 @@ export class CommandHandler {
};
}
private async handleVoiceTransmitStart(cmd: BackendCommand): Promise<CommandReply> {
private async handleVoiceTransmitStart(
cmd: BackendCommand,
): Promise<CommandReply> {
if (!discordPlayer.isConnected()) {
return {
id: cmd.id,
@@ -412,7 +414,9 @@ export class CommandHandler {
}
}
private async handleVoiceTransmitStop(cmd: BackendCommand): Promise<CommandReply> {
private async handleVoiceTransmitStop(
cmd: BackendCommand,
): Promise<CommandReply> {
try {
await voiceTransmitter.stop();
logger.info("Voice transmit stopped");
@@ -464,11 +468,9 @@ export class CommandHandler {
* Fire-and-forget SET using the persistent Redis publisher connection.
*/
private setKey(key: string, value: string): void {
this.redisPub
.set(key, value)
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn({ key, error: msg }, "Failed to update Redis status key");
});
this.redisPub.set(key, value).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn({ key, error: msg }, "Failed to update Redis status key");
});
}
}
@@ -138,7 +138,7 @@ export class EventBroadcaster {
async voicePcmData(
pcmBuffer: Buffer,
userId: string,
metadata?: any,
metadata?: Record<string, unknown>,
): Promise<void> {
await this.publisher.publish("discord:voice:pcm", {
type: "voice_pcm_data",
@@ -1,929 +0,0 @@
import { createChildLogger } from "@bete/shared/logger";
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
import type { MessageRecord } from "./types.js";
const logger = createChildLogger("analytics-store");
// ── Types ──────────────────────────────────────────────────────────────
export interface HourlyBucket {
hour: string;
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
export interface TopicTrend {
topic: string;
count: number;
score: number;
}
export interface UserStat {
user_id: string;
username: string;
avatar_url: string | null;
message_count: number;
edited_count: number;
deleted_count: number;
flagged_count: number;
last_active: number;
}
export interface ModerationBreakdown {
total: number;
clean: number;
warned: number;
flagged: number;
error: number;
pending: number;
average_score: number;
}
export interface AnalyticsOverview {
period: { start: number; end: number };
messages: ModerationBreakdown;
hourly: HourlyBucket[];
topics: TopicTrend[];
top_users: UserStat[];
active_users_count: number;
total_channels: number;
}
// ══════════════════════════════════════════════════════════════════════════
// GENERIC QUERY CACHE (reduces duplicate DB calls from 5s auto-refresh)
// ══════════════════════════════════════════════════════════════════════════
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
const queryCache = new Map<string, CacheEntry<any>>();
/** Default TTL for aggregate queries — 10s is long enough to prevent redundant
* calls from the 5s auto-refresh but short enough to feel real-time. */
const AGGREGATE_CACHE_TTL_MS = 10_000;
/** Topic extraction is expensive (JSON parsing). Cache longer. */
const TOPIC_CACHE_TTL_MS = 120_000;
function makeCacheKey(prefix: string, params: Record<string, any>): string {
return `${prefix}:${JSON.stringify(params)}`;
}
function getCached<T>(key: string): T | undefined {
const entry = queryCache.get(key);
if (entry && entry.expiresAt > Date.now()) return entry.data;
if (entry) queryCache.delete(key); // expired
return undefined;
}
function setCache<T>(key: string, data: T, ttl: number): void {
queryCache.set(key, { data, expiresAt: Date.now() + ttl });
// Prune old entries if cache grows too large (>200 entries)
if (queryCache.size > 200) {
const now = Date.now();
for (const [k, v] of queryCache) {
if (v.expiresAt <= now) queryCache.delete(k);
}
}
}
// ── Hourly Message Stats ───────────────────────────────────────────────
export async function getHourlyStats(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<HourlyBucket[]> {
const { guildId, channelId, hours = 24 } = input;
const cacheKey = makeCacheKey("hourly", { guildId, channelId, hours });
const cached = getCached<HourlyBucket[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const hourExpr = `to_char(to_timestamp((created_at / 3600000) * 3600), 'YYYY-MM-DD HH24:MI:SS') as hour`;
const rows = await executeAll(
`
SELECT
${hourExpr},
count(*) as count,
count(case when ai_status = 'clean' then 1 end) as clean,
count(case when ai_status = 'warn' then 1 end) as warned,
count(case when ai_status = 'flagged' then 1 end) as flagged,
count(case when ai_status = 'error' then 1 end) as error
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY (created_at / 3600000)
ORDER BY hour ASC
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
// Initialize all hour buckets (fill gaps with zeros)
const buckets = new Map<
string,
{
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
>();
for (let h = 0; h < hours; h++) {
const ts = new Date(since + h * 3600_000);
ts.setMinutes(0, 0, 0);
const key = ts.toISOString().slice(0, 13) + ":00:00Z";
buckets.set(key, { count: 0, clean: 0, warned: 0, flagged: 0, error: 0 });
}
for (const row of rows) {
const d = new Date(row.hour.replace(" ", "T") + "Z");
const key = d.toISOString().slice(0, 13) + ":00:00Z";
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.count = row.count;
bucket.clean = row.clean;
bucket.warned = row.warned;
bucket.flagged = row.flagged;
bucket.error = row.error;
}
const result = Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([hour, data]) => ({ hour, ...data }));
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get hourly stats",
);
return [];
}
}
// ── Topic Trends ───────────────────────────────────────────────────────
const STOP_WORDS = new Set([
"yang",
"dan",
"itu",
"ini",
"dengan",
"akan",
"pada",
"dari",
"di",
"ke",
"untuk",
"tidak",
"ada",
"juga",
"sudah",
"saya",
"kamu",
"dia",
"mereka",
"kami",
"aku",
"lo",
"lu",
"gua",
"gue",
"org",
"orang",
"aja",
"sama",
"kalo",
"kalau",
"bisa",
"karena",
"gak",
"nggak",
"ga",
"tak",
"belum",
"udah",
"dah",
"lah",
"kah",
"pun",
"nih",
"tuh",
"deh",
"dong",
"si",
"nya",
"kan",
"ya",
"yah",
"yuk",
"kok",
"loh",
"nah",
"wow",
"eh",
"the",
"a",
"an",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"having",
"do",
"does",
"did",
"doing",
"will",
"would",
"could",
"should",
"may",
"might",
"must",
"shall",
"i",
"you",
"he",
"she",
"it",
"we",
"they",
"me",
"him",
"her",
"us",
"them",
"my",
"your",
"his",
"its",
"our",
"their",
"and",
"but",
"or",
"nor",
"not",
"so",
"yet",
"for",
"if",
"to",
"of",
"in",
"on",
"at",
"by",
"as",
"with",
"about",
"just",
"then",
"now",
"here",
"there",
"when",
"where",
"why",
"how",
"all",
"both",
"each",
"few",
"more",
"most",
"other",
"some",
"such",
"only",
"own",
"same",
"too",
"very",
"can",
"go",
"ok",
"okay",
"yeah",
"yes",
"no",
]);
function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] {
const topicScores = new Map<string, { count: number; score: number }>();
const wordFreq = new Map<string, number>();
const flaggedWordFreq = new Map<string, number>();
for (const msg of messages) {
if (msg.ai_analysis) {
try {
const analysis = JSON.parse(msg.ai_analysis);
const topics = analysis.topics;
if (topics && Array.isArray(topics)) {
for (const topic of topics) {
const key =
typeof topic === "string" ? topic : topic.name || topic.topic;
if (!key) continue;
const k = key.toLowerCase();
const score = msg.ai_moderation_score || 0;
const existing = topicScores.get(k);
if (existing) {
existing.count++;
existing.score += score;
} else {
topicScores.set(k, { count: 1, score });
}
}
}
if (analysis.category) {
const cat = String(analysis.category).toLowerCase();
const existing = topicScores.get(cat);
if (existing) {
existing.count++;
existing.score += msg.ai_moderation_score || 0;
} else {
topicScores.set(cat, {
count: 1,
score: msg.ai_moderation_score || 0,
});
}
}
} catch {
/* not valid JSON */
}
}
if (msg.content) {
const words = msg.content
.toLowerCase()
.replace(/[^\w\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
for (const word of words) {
wordFreq.set(word, (wordFreq.get(word) || 0) + 1);
if (msg.ai_status === "flagged" || msg.ai_status === "warn") {
flaggedWordFreq.set(word, (flaggedWordFreq.get(word) || 0) + 1);
}
}
}
}
const results: TopicTrend[] = [];
for (const [topic, data] of topicScores) {
results.push({ topic, count: data.count, score: data.score });
}
const sortedWords = Array.from(wordFreq.entries())
.sort(([, a], [, b]) => b - a)
.slice(0, topN);
for (const [word, count] of sortedWords) {
if (!topicScores.has(word)) {
results.push({
topic: word,
count,
score: flaggedWordFreq.get(word) || 0,
});
}
}
return results.sort((a, b) => b.count - a.count).slice(0, topN);
}
export async function getTopicTrends(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<TopicTrend[]> {
const { guildId, channelId, hours = 24 } = input;
const cacheKey = makeCacheKey("topics", { guildId, channelId, hours });
const cached = getCached<TopicTrend[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
// Fetch all analyzed messages within the time window (no hard row cap).
// Messages without ai_analysis are excluded which naturally limits rows.
const rows = (await executeAll(
`
SELECT
id, content, ai_status, ai_analysis, ai_moderation_score,
ai_moderation_flags, created_at
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
AND ai_analysis IS NOT NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
ORDER BY created_at DESC
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
)) as MessageRecord[];
const result = extractTopics(rows);
setCache(cacheKey, result, TOPIC_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get topic trends",
);
return [];
}
}
// ── User Leaderboard ────────────────────────────────────────────────────
export async function getUserLeaderboard(input: {
guildId: string;
channelId?: string;
hours?: number;
limit?: number;
}): Promise<UserStat[]> {
const { guildId, channelId, hours = 24, limit = 20 } = input;
const cacheKey = makeCacheKey("leaderboard", {
guildId,
channelId,
hours,
limit,
});
const cached = getCached<UserStat[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const rows = await executeAll(
`
SELECT
user_id,
username,
avatar_url,
count(*) as message_count,
count(case when type = 'edited' then 1 end) as edited_count,
count(case when type = 'deleted' then 1 end) as deleted_count,
count(case when ai_status = 'flagged' then 1 end) as flagged_count,
max(created_at) as last_active
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY user_id, username, avatar_url
ORDER BY message_count DESC
LIMIT ?
`,
channelId
? [guildId, since, channelId, channelId, limit]
: [guildId, since, limit],
);
const result = rows as UserStat[];
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get user leaderboard",
);
return [];
}
}
// ── Moderation Stats ───────────────────────────────────────────────────
export async function getModerationStats(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<ModerationBreakdown> {
const { guildId, channelId, hours = 24 } = input;
const cacheKey = makeCacheKey("modstats", { guildId, channelId, hours });
const cached = getCached<ModerationBreakdown>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const avgScoreExpr = `round(avg(ai_moderation_score)::numeric, 2)`;
const row = await executeGet(
`
SELECT
count(*) as total,
count(case when ai_status = 'clean' then 1 end) as clean,
count(case when ai_status = 'warn' then 1 end) as warned,
count(case when ai_status = 'flagged' then 1 end) as flagged,
count(case when ai_status = 'error' then 1 end) as error,
count(case when ai_status = 'pending' or ai_status IS NULL then 1 end) as pending,
${avgScoreExpr} as average_score
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
const result: ModerationBreakdown = row
? {
total: row.total ?? 0,
clean: row.clean ?? 0,
warned: row.warned ?? 0,
flagged: row.flagged ?? 0,
error: row.error ?? 0,
pending: row.pending ?? 0,
average_score: row.average_score ?? 0,
}
: {
total: 0,
clean: 0,
warned: 0,
flagged: 0,
error: 0,
pending: 0,
average_score: 0,
};
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get moderation stats",
);
return {
total: 0,
clean: 0,
warned: 0,
flagged: 0,
error: 0,
pending: 0,
average_score: 0,
};
}
}
// ── Active Channels Count ──────────────────────────────────────────────
export async function getActiveChannelCount(input: {
guildId: string;
hours?: number;
}): Promise<number> {
const { guildId, hours = 24 } = input;
const cacheKey = makeCacheKey("channels", { guildId, hours });
const cached = getCached<number>(cacheKey);
if (cached !== undefined) return cached;
try {
const since = Date.now() - hours * 3600_000;
const row = await executeGet(
`
SELECT count(DISTINCT channel_id) as cnt
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
`,
[guildId, since],
);
const result = row?.cnt ?? 0;
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get active channel count",
);
return 0;
}
}
// ── Top Violators ─────────────────────────────────────────────────────
export interface ViolatorStat {
user_id: string;
username: string;
avatar_url: string | null;
total_messages: number;
flagged_count: number;
warned_count: number;
violation_score: number;
worst_flags: string[];
last_violation: number;
}
export async function getTopViolators(input: {
guildId: string;
channelId?: string;
hours?: number;
limit?: number;
}): Promise<ViolatorStat[]> {
const { guildId, channelId, hours = 24, limit = 20 } = input;
const cacheKey = makeCacheKey("violators", {
guildId,
channelId,
hours,
limit,
});
const cached = getCached<ViolatorStat[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const rows = await executeAll(
`
SELECT
user_id,
username,
avatar_url,
count(*) as total_messages,
count(case when ai_status = 'flagged' then 1 end) as flagged_count,
count(case when ai_status = 'warn' then 1 end) as warned_count,
max(case when ai_status in ('flagged', 'warn') then created_at else 0 end) as last_violation
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY user_id, username, avatar_url
HAVING count(case when ai_status = 'flagged' then 1 end) > 0
OR count(case when ai_status = 'warn' then 1 end) > 0
ORDER BY (
count(case when ai_status = 'flagged' then 1 end) * 3
+ count(case when ai_status = 'warn' then 1 end)
) DESC
LIMIT ?
`,
channelId
? [guildId, since, channelId, channelId, limit]
: [guildId, since, limit],
);
const violators: ViolatorStat[] = rows.map((row: any) => {
const flaggedCount = Number(row.flagged_count ?? 0);
const warnedCount = Number(row.warned_count ?? 0);
return {
user_id: row.user_id,
username: row.username,
avatar_url: row.avatar_url,
total_messages: Number(row.total_messages ?? 0),
flagged_count: flaggedCount,
warned_count: warnedCount,
violation_score: flaggedCount * 3 + warnedCount,
worst_flags: [],
last_violation: Number(row.last_violation ?? 0),
};
});
setCache(cacheKey, violators, AGGREGATE_CACHE_TTL_MS);
return violators;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get top violators",
);
return [];
}
}
// ── Daily Trend (for multi-day line chart) ────────────────────────────
export interface TrendBucket {
date: string;
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
export async function getDailyTrend(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<TrendBucket[]> {
const { guildId, channelId, hours = 168 } = input;
const cacheKey = makeCacheKey("daily_trend", { guildId, channelId, hours });
const cached = getCached<TrendBucket[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const dateExpr = `to_char(date_trunc('day', to_timestamp(created_at / 1000)), 'YYYY-MM-DD') as date`;
const rows = await executeAll(
`
SELECT
${dateExpr},
count(*) as count,
count(case when ai_status = 'clean' then 1 end) as clean,
count(case when ai_status = 'warn' then 1 end) as warned,
count(case when ai_status = 'flagged' then 1 end) as flagged,
count(case when ai_status = 'error' then 1 end) as error
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY 1
ORDER BY 1 ASC
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
// Initialize all day buckets (fill gaps with zeros)
const buckets = new Map<
string,
{
count: number;
clean: number;
warned: number;
flagged: number;
error: number;
}
>();
const msPerDay = 86400_000;
const startDay = Math.floor(since / msPerDay) * msPerDay;
const endDay = Math.floor(Date.now() / msPerDay) * msPerDay;
for (let d = startDay; d <= endDay; d += msPerDay) {
const key = new Date(d).toISOString().slice(0, 10);
buckets.set(key, { count: 0, clean: 0, warned: 0, flagged: 0, error: 0 });
}
for (const row of rows) {
const bucket = buckets.get(row.date);
if (!bucket) continue;
bucket.count = row.count;
bucket.clean = row.clean;
bucket.warned = row.warned;
bucket.flagged = row.flagged;
bucket.error = row.error;
}
const result = Array.from(buckets.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, data]) => ({ date, ...data }));
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get daily trend",
);
return [];
}
}
// ── Activity Heatmap (day-of-week × hour-of-day) ──────────────────────
export interface HeatmapCell {
dayOfWeek: number; // 0=Senin, 6=Minggu
hour: number; // 0-23
count: number;
clean: number;
warned: number;
flagged: number;
}
export async function getActivityHeatmap(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<HeatmapCell[]> {
const { guildId, channelId, hours = 168 } = input;
const cacheKey = makeCacheKey("heatmap", { guildId, channelId, hours });
const cached = getCached<HeatmapCell[]>(cacheKey);
if (cached) return cached;
try {
const since = Date.now() - hours * 3600_000;
const dayExpr = `(extract(isodow from to_timestamp(created_at / 1000)) % 7)::int as day_of_week`;
const hourExpr = `extract(hour from to_timestamp(created_at / 1000))::int as hour`;
const rows = await executeAll(
`
SELECT
${dayExpr},
${hourExpr},
count(*) as count,
count(case when ai_status = 'clean' then 1 end) as clean,
count(case when ai_status = 'warn' then 1 end) as warned,
count(case when ai_status = 'flagged' then 1 end) as flagged
FROM messages
WHERE guild_id = ?
AND created_at >= ?
AND deleted_at IS NULL
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
GROUP BY day_of_week, hour
ORDER BY day_of_week, hour
`,
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
);
// Initialize all 7×24 cells with zeros
const cells = new Map<
string,
{ count: number; clean: number; warned: number; flagged: number }
>();
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
cells.set(`${d}-${h}`, { count: 0, clean: 0, warned: 0, flagged: 0 });
}
}
for (const row of rows) {
const key = `${row.day_of_week}-${row.hour}`;
const cell = cells.get(key);
if (!cell) continue;
cell.count = row.count;
cell.clean = row.clean;
cell.warned = row.warned;
cell.flagged = row.flagged;
}
const result = Array.from(cells.entries())
.map(([key, data]) => {
const [dayOfWeek, hour] = key.split("-").map(Number);
return { dayOfWeek, hour, ...data };
})
.sort((a, b) => a.dayOfWeek - b.dayOfWeek || a.hour - b.hour);
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
return result;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to get activity heatmap",
);
return [];
}
}
// ── Cache Invalidation (called when new messages arrive) ───────────────
export function invalidateAnalyticsCache(guildId: string): void {
const now = Date.now();
const needle = `"${guildId}"`;
for (const [key, entry] of queryCache) {
if (key.includes(needle) && entry.expiresAt > now) {
entry.expiresAt = 0; // expire immediately
}
}
}
// ── Combined Overview ──────────────────────────────────────────────────
export async function getAnalyticsOverview(input: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<AnalyticsOverview> {
const { guildId, hours = 24 } = input;
const now = Date.now();
const since = now - hours * 3600_000;
const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all(
[
getModerationStats(input),
getHourlyStats(input),
getTopicTrends(input),
getUserLeaderboard(input),
getActiveChannelCount({ guildId, hours }),
],
);
return {
period: { start: since, end: now },
messages,
hourly,
topics,
top_users: topUsers,
active_users_count: topUsers.length,
total_channels: totalChannels,
};
}
@@ -672,7 +672,7 @@ export async function getPendingMessagesByConversation(
.limit(limit)
.for("update", { skipLocked: true });
const pendingIds = await pendingIdsQuery;
const pendingIds = (await pendingIdsQuery) as Array<{ id: string }>;
if (pendingIds.length === 0) return [];
@@ -682,7 +682,7 @@ export async function getPendingMessagesByConversation(
.where(
inArray(
messagesTable.id,
(pendingIds as any[]).map((r) => r.id as string),
pendingIds.map((r) => r.id),
),
)
.returning();
@@ -897,7 +897,7 @@ export async function getIncompleteMessagesByConversation(
.limit(limit)
.for("update", { skipLocked: true });
const pendingIds = await pendingIdsQuery;
const pendingIds = (await pendingIdsQuery) as Array<{ id: string }>;
if (pendingIds.length === 0) return [];
@@ -907,7 +907,7 @@ export async function getIncompleteMessagesByConversation(
.where(
inArray(
messagesTable.id,
(pendingIds as any[]).map((r) => r.id as string),
pendingIds.map((r) => r.id),
),
)
.returning();
@@ -177,6 +177,22 @@ export interface AnalysisResult {
evidence?: string[];
}
export interface VoiceRecordingUploadData {
id: string;
user_id: string;
username: string;
avatar_url: string | null;
guild_id: string | null;
channel_id: string | null;
channel_name: string | null;
filename: string;
size_bytes: number;
download_url: string;
upload_status: string;
created_at: number;
uploaded_at: number;
}
export type ModerationWsEvent =
| { type: "ui_state"; state: unknown }
| { type: "user_state"; users: unknown[] }
@@ -187,7 +203,7 @@ export type ModerationWsEvent =
| { type: "attachment_created"; data: AttachmentRecord }
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
| { type: "media_state"; state: unknown }
| { type: "voice_recording_uploaded"; data: any };
| { type: "voice_recording_uploaded"; data: VoiceRecordingUploadData };
export interface AnalysisQueueStatus {
queuedConversations: number;
@@ -1,4 +1,4 @@
import { spawn } from "child_process";
import { spawn } from "node:child_process";
export interface MuxFfmpegArgsOptions {
inputs: string[];
@@ -1,5 +1,5 @@
export { OpusDecoder } from "./recorder/decoder.js";
export { SegmentManager } from "./recorder/segment.js";
export { startRecording, stopRecording } from "./recorder.js";
export { VoiceController } from "./voiceController.js";
export { voiceTransmitter } from "./transmitter.js";
export { VoiceController } from "./voiceController.js";
@@ -91,18 +91,22 @@ export async function uploadRecordingSegment(input: {
timestamp: Date.now(),
});
broadcaster.getClients().forEach((client: any) => {
if (client.readyState === 1) {
try {
client.send(payload);
} catch (err) {
logger.warn(
{ err },
"Failed to send recording upload event to client",
);
}
}
});
broadcaster
.getClients()
.forEach(
(client: { readyState: number; send: (data: string) => void }) => {
if (client.readyState === 1) {
try {
client.send(payload);
} catch (err) {
logger.warn(
{ err },
"Failed to send recording upload event to client",
);
}
}
},
);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -1,5 +1,5 @@
import { PassThrough } from "node:stream";
import { spawn } from "node:child_process";
import { PassThrough } from "node:stream";
import { createChildLogger } from "@bete/shared/logger";
import { StreamType } from "@discordjs/voice";
import type Redis from "ioredis";
@@ -39,23 +39,39 @@ export class VoiceTransmitter {
// Spawn FFmpeg to encode 24kHz mono PCM → OggOpus
// Input: 24kHz mono s16le (raw PCM)
// Output: OGG container with Opus audio
this.ffmpegProcess = spawn("ffmpeg", [
"-f", "s16le", // Input format: signed 16-bit little-endian
"-ar", "24000", // Input sample rate: 24kHz
"-ac", "1", // Input channels: mono
"-i", "pipe:0", // Read from stdin
"-f", "ogg", // Output format: OGG
"-c:a", "libopus", // Codec: Opus
"-b:a", "96k", // Bitrate: 96kbps
"-ar", "48000", // Output sample rate: 48kHz
"-ac", "2", // Output channels: stereo
"-application", "lowdelay", // Low delay mode for real-time
"-frame_duration", "20", // 20ms frames
"-packet_loss", "0", // No packet loss expected
"pipe:1", // Write to stdout
], {
stdio: ["pipe", "pipe", "pipe"],
});
this.ffmpegProcess = spawn(
"ffmpeg",
[
"-f",
"s16le", // Input format: signed 16-bit little-endian
"-ar",
"24000", // Input sample rate: 24kHz
"-ac",
"1", // Input channels: mono
"-i",
"pipe:0", // Read from stdin
"-f",
"ogg", // Output format: OGG
"-c:a",
"libopus", // Codec: Opus
"-b:a",
"96k", // Bitrate: 96kbps
"-ar",
"48000", // Output sample rate: 48kHz
"-ac",
"2", // Output channels: stereo
"-application",
"lowdelay", // Low delay mode for real-time
"-frame_duration",
"20", // 20ms frames
"-packet_loss",
"0", // No packet loss expected
"pipe:1", // Write to stdout
],
{
stdio: ["pipe", "pipe", "pipe"],
},
);
// Pipe PCM data to FFmpeg stdin
if (this.ffmpegProcess.stdin) {
@@ -69,16 +85,20 @@ export class VoiceTransmitter {
});
this.ffmpegProcess.on("error", (err) => {
const msg = err.message === "spawn ffmpeg ENOENT"
? "FFmpeg/avconv not found! Install ffmpeg in the container."
: err.message;
const msg =
err.message === "spawn ffmpeg ENOENT"
? "FFmpeg/avconv not found! Install ffmpeg in the container."
: err.message;
logger.error({ error: msg }, "FFmpeg process error");
});
this.ffmpegProcess.on("exit", (code) => {
if (code !== 0) {
const stderr = Buffer.concat(stderrChunks).toString();
logger.error({ code, stderr: stderr.slice(-500) }, "FFmpeg exited with error");
logger.error(
{ code, stderr: stderr.slice(-500) },
"FFmpeg exited with error",
);
}
});
@@ -90,11 +110,16 @@ export class VoiceTransmitter {
});
}
logger.info("Voice transmitter pipeline ready (PCM → FFmpeg → OggOpus → Discord)");
logger.info(
"Voice transmitter pipeline ready (PCM → FFmpeg → OggOpus → Discord)",
);
// Subscribe to Redis channel for PCM data
await this.redisSub.subscribe(this.TRANSMIT_CHANNEL);
logger.info({ channel: this.TRANSMIT_CHANNEL }, "Subscribed to transmit channel");
logger.info(
{ channel: this.TRANSMIT_CHANNEL },
"Subscribed to transmit channel",
);
this.redisSub.on("message", (channel, message) => {
if (channel !== this.TRANSMIT_CHANNEL || !this.pcmStream) return;
@@ -1,6 +1,6 @@
import "dotenv/config";
import { ConfigError } from "@bete/shared/errors";
import { z } from "zod";
import { ConfigError } from "../errors/errors.js";
const configSchema = z
.object({
@@ -1,50 +0,0 @@
export class AppError extends Error {
public code: string;
public statusCode: number;
constructor(message: string, code: string, statusCode: number = 500) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.name = "AppError";
Error.captureStackTrace(this, this.constructor);
}
}
export class ConfigError extends AppError {
constructor(message: string) {
super(message, "CONFIG_ERROR", 500);
this.name = "ConfigError";
}
}
export class AudioError extends AppError {
constructor(message: string) {
super(message, "AUDIO_ERROR", 500);
this.name = "AudioError";
}
}
export class DatabaseError extends AppError {
constructor(message: string) {
super(message, "DATABASE_ERROR", 500);
this.name = "DatabaseError";
}
}
export class VoiceConnectionError extends AppError {
constructor(message: string) {
super(message, "VOICE_CONNECTION_ERROR", 500);
this.name = "VoiceConnectionError";
}
}
export class ValidationError extends AppError {
public details?: Record<string, string[]>;
constructor(message: string, details?: Record<string, string[]>) {
super(message, "VALIDATION_ERROR", 400);
this.details = details;
this.name = "ValidationError";
}
}