fix: organize imports and apply linting fixes

This commit is contained in:
MythEclipse
2026-05-14 15:02:23 +07:00
parent 1623c612c3
commit d1282f2f57
15 changed files with 477 additions and 199 deletions

View File

@@ -1,7 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { ConfigError } from "./errors"; import { ConfigError } from "./errors";
const configSchema = z.object({ const configSchema = z
.object({
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
VOICE_CHANNEL_ID: z.string().min(1).optional(), VOICE_CHANNEL_ID: z.string().min(1).optional(),
GUILD_ID: z.string().min(1).optional(), GUILD_ID: z.string().min(1).optional(),
@@ -17,7 +18,10 @@ const configSchema = z.object({
WEBSERVER_PORT: z.coerce.number().positive().default(3000), WEBSERVER_PORT: z.coerce.number().positive().default(3000),
VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000), VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000),
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000), RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce.number().positive().default(3000), AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce
.number()
.positive()
.default(3000),
PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8), PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8),
OPUS_FRAME_SIZE: z.coerce.number().positive().default(960), OPUS_FRAME_SIZE: z.coerce.number().positive().default(960),
AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000), AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000),
@@ -28,22 +32,36 @@ const configSchema = z.object({
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),
MONITOR_GUILD_ID: z.string().min(1).optional(), MONITOR_GUILD_ID: z.string().min(1).optional(),
PICSER_UPLOAD_URL: z.string().url().default("https://picser.asepharyana.tech/api/upload"), PICSER_UPLOAD_URL: z
.string()
.url()
.default("https://picser.asepharyana.tech/api/upload"),
ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000), ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000),
ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100), ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100),
ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3), ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3),
BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24), BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24),
BACKLOG_SYNC_BATCH_SIZE: z.coerce.number().int().positive().max(100).default(100), BACKLOG_SYNC_BATCH_SIZE: z.coerce
.number()
.int()
.positive()
.max(100)
.default(100),
AI_ANALYSIS_ENABLED: z AI_ANALYSIS_ENABLED: z
.string() .string()
.optional() .optional()
.transform((v) => v === "true") .transform((v) => v === "true")
.default(false), .default(false),
OPENAI_MODERATION_API_KEY: z.string().optional(), OPENAI_MODERATION_API_KEY: z.string().optional(),
OPENAI_MODERATION_BASE_URL: z.string().url().default("https://api.openai.com/v1"), OPENAI_MODERATION_BASE_URL: z
.string()
.url()
.default("https://api.openai.com/v1"),
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"), OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
AI_LLM_API_KEY: z.string().optional(), AI_LLM_API_KEY: z.string().optional(),
AI_LLM_BASE_URL: z.string().url().default("https://9router.asepharyana.tech/v1"), AI_LLM_BASE_URL: z
.string()
.url()
.default("https://9router.asepharyana.tech/v1"),
AI_LLM_MODEL: z.string().default("free"), AI_LLM_MODEL: z.string().default("free"),
AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000), AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000),
DATABASE_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"), DATABASE_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"),
@@ -55,7 +73,8 @@ const configSchema = z.object({
POSTGRES_DB: z.string().optional(), POSTGRES_DB: z.string().optional(),
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2), POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10), POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
}).superRefine((value, ctx) => { })
.superRefine((value, ctx) => {
if (!value.AI_ANALYSIS_ENABLED) { if (!value.AI_ANALYSIS_ENABLED) {
// Continue to database validation // Continue to database validation
} else if (!value.AI_LLM_API_KEY) { } else if (!value.AI_LLM_API_KEY) {
@@ -72,11 +91,12 @@ const configSchema = z.object({
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["DATABASE_URL"], path: ["DATABASE_URL"],
message: "Either DATABASE_URL or POSTGRES_HOST must be provided when DATABASE_TYPE=postgres", message:
"Either DATABASE_URL or POSTGRES_HOST must be provided when DATABASE_TYPE=postgres",
}); });
} }
} }
}); });
export type AppConfig = z.infer<typeof configSchema>; export type AppConfig = z.infer<typeof configSchema>;

View File

@@ -1,7 +1,7 @@
import path from "node:path"; import path from "node:path";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { createChildLogger } from "../logger";
import { config } from "../config"; import { config } from "../config";
import { createChildLogger } from "../logger";
import * as postgres from "./postgres"; import * as postgres from "./postgres";
const logger = createChildLogger("db-adapter"); const logger = createChildLogger("db-adapter");

View File

@@ -1,6 +1,6 @@
import { Pool, PoolClient, QueryResult, QueryResultRow } from "pg"; import { Pool, PoolClient, QueryResult, QueryResultRow } from "pg";
import { createChildLogger } from "../logger";
import { config } from "../config"; import { config } from "../config";
import { createChildLogger } from "../logger";
const logger = createChildLogger("postgres"); const logger = createChildLogger("postgres");

View File

@@ -4,19 +4,22 @@ import "@snazzah/davey";
import "dotenv/config"; import "dotenv/config";
import { Client } from "discord.js-selfbot-v13"; import { Client } from "discord.js-selfbot-v13";
import { config } from "./config"; import { config } from "./config";
import { getDatabase } from "./database/adapter";
import { createChildLogger } from "./logger"; import { createChildLogger } from "./logger";
import { startPendingAIAnalysisWorker } from "./moderation/aiAnalyzer";
import { syncBacklogMessages } from "./moderation/backlogSync";
import { registerMessageCapture } from "./moderation/messageCapture";
import { discordPlayer } from "./player"; import { discordPlayer } from "./player";
import { VoiceController } from "./voiceController"; import { VoiceController } from "./voiceController";
import { startWebserver } from "./webserver"; import { startWebserver } from "./webserver";
import { registerMessageCapture } from "./moderation/messageCapture";
import { syncBacklogMessages } from "./moderation/backlogSync";
import { getDatabase } from "./database/adapter";
import { startPendingAIAnalysisWorker } from "./moderation/aiAnalyzer";
const logger = createChildLogger("bot"); const logger = createChildLogger("bot");
const token = config.DISCORD_TOKEN; const token = config.DISCORD_TOKEN;
logger.info({ hasToken: token.length > 0, tokenLength: token.length }, "Config loaded"); logger.info(
{ hasToken: token.length > 0, tokenLength: token.length },
"Config loaded",
);
logger.info("Creating Discord client"); logger.info("Creating Discord client");
const client = new Client(); const client = new Client();
@@ -105,9 +108,12 @@ async function initializeApp() {
}); });
logger.info("Calling Discord client.login"); logger.info("Calling Discord client.login");
client.login(token).then(() => { client
.login(token)
.then(() => {
logger.info("Discord client.login resolved"); logger.info("Discord client.login resolved");
}).catch((error) => { })
.catch((error) => {
logger.error({ error }, "Discord client.login failed"); logger.error({ error }, "Discord client.login failed");
}); });
} }

View File

@@ -7,7 +7,8 @@ export const logger = pino({
serializers: { serializers: {
error: pino.stdSerializers.err, error: pino.stdSerializers.err,
err: pino.stdSerializers.err, err: pino.stdSerializers.err,
reason: (value) => value instanceof Error ? pino.stdSerializers.err(value) : value, reason: (value) =>
value instanceof Error ? pino.stdSerializers.err(value) : value,
}, },
transport: isDev transport: isDev
? { ? {

View File

@@ -2,7 +2,11 @@ import { config } from "../config";
import { createChildLogger } from "../logger"; import { createChildLogger } from "../logger";
import type { SqliteDatabase } from "../muxer-queue"; import type { SqliteDatabase } from "../muxer-queue";
import { retryWithBackoff } from "../retry"; import { retryWithBackoff } from "../retry";
import { getMessageById, getPendingAIAnalysisMessages, updateMessageAIAnalysis } from "./messageStore"; import {
getMessageById,
getPendingAIAnalysisMessages,
updateMessageAIAnalysis,
} from "./messageStore";
import type { MessageRecord } from "./types"; import type { MessageRecord } from "./types";
const logger = createChildLogger("ai-analyzer"); const logger = createChildLogger("ai-analyzer");
@@ -37,7 +41,10 @@ function estimateTokens(text: string): number {
return Math.ceil(text.length / 4); return Math.ceil(text.length / 4);
} }
function formatMessageForAnalysis(message: MessageRecord, index: number): string { function formatMessageForAnalysis(
message: MessageRecord,
index: number,
): string {
const text = getAnalysisText(message); const text = getAnalysisText(message);
const time = new Date(message.created_at).toISOString(); const time = new Date(message.created_at).toISOString();
return `${index + 1}. id=${message.id} time=${time} user=${message.username}: ${text}`; return `${index + 1}. id=${message.id} time=${time} user=${message.username}: ${text}`;
@@ -49,7 +56,10 @@ function estimateMessageTokens(message: MessageRecord): number {
async function fetchJson(url: string, init: RequestInit): Promise<unknown> { async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.AI_ANALYSIS_TIMEOUT_MS); const timeout = setTimeout(
() => controller.abort(),
config.AI_ANALYSIS_TIMEOUT_MS,
);
try { try {
const response = await fetch(url, { ...init, signal: controller.signal }); const response = await fetch(url, { ...init, signal: controller.signal });
@@ -85,10 +95,16 @@ function parseLLMAnalysis(content: string): LLMAnalysis {
if (jsonStart >= 0 && jsonEnd > jsonStart) { if (jsonStart >= 0 && jsonEnd > jsonStart) {
try { try {
const parsed = JSON.parse(content.slice(jsonStart, jsonEnd + 1)); const parsed = JSON.parse(content.slice(jsonStart, jsonEnd + 1));
const status = parsed.status === "flagged" ? "flagged" : parsed.status === "warn" ? "warn" : "clean"; const status =
parsed.status === "flagged"
? "flagged"
: parsed.status === "warn"
? "warn"
: "clean";
const flags = Array.isArray(parsed.flags) ? parsed.flags.map(String) : []; const flags = Array.isArray(parsed.flags) ? parsed.flags.map(String) : [];
const score = Math.max(0, Math.min(1, Number(parsed.score) || 0)); const score = Math.max(0, Math.min(1, Number(parsed.score) || 0));
const analysis = typeof parsed.analysis === "string" ? parsed.analysis : content; const analysis =
typeof parsed.analysis === "string" ? parsed.analysis : content;
return { status, flags, score, analysis }; return { status, flags, score, analysis };
} catch { } catch {
// Fall through to text-only parsing. // Fall through to text-only parsing.
@@ -96,19 +112,29 @@ function parseLLMAnalysis(content: string): LLMAnalysis {
} }
return { return {
status: /flagged|bahaya|berisiko|toxic|hate|harassment|violence|sexual|self-harm|illegal|scam|hacking/i.test(content) ? "flagged" : /warn|provokasi|hinaan|menyerang/i.test(content) ? "warn" : "clean", status:
/flagged|bahaya|berisiko|toxic|hate|harassment|violence|sexual|self-harm|illegal|scam|hacking/i.test(
content,
)
? "flagged"
: /warn|provokasi|hinaan|menyerang/i.test(content)
? "warn"
: "clean",
flags: [], flags: [],
score: 0, score: 0,
analysis: content.trim() || "Tidak ada analisis dari LLM.", analysis: content.trim() || "Tidak ada analisis dari LLM.",
}; };
} }
async function runLLMAnalysis(messages: MessageRecord[]): Promise<{ results: LLMAnalysis[]; raw: unknown }> { async function runLLMAnalysis(
const response = await retryWithBackoff( messages: MessageRecord[],
() => fetchJson(`${config.AI_LLM_BASE_URL}/chat/completions`, { ): Promise<{ results: LLMAnalysis[]; raw: unknown }> {
const response = (await retryWithBackoff(
() =>
fetchJson(`${config.AI_LLM_BASE_URL}/chat/completions`, {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${config.AI_LLM_API_KEY}`, Authorization: `Bearer ${config.AI_LLM_API_KEY}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -177,7 +203,7 @@ Satu JSON object per pesan dalam array.`,
signal: AbortSignal.timeout(config.AI_ANALYSIS_TIMEOUT_MS), signal: AbortSignal.timeout(config.AI_ANALYSIS_TIMEOUT_MS),
}), }),
{ retries: 2, logger }, { retries: 2, logger },
) as ChatCompletionResponse; )) as ChatCompletionResponse;
const content = response.choices?.[0]?.message?.content?.trim() || ""; const content = response.choices?.[0]?.message?.content?.trim() || "";
@@ -191,12 +217,18 @@ Satu JSON object per pesan dalam array.`,
const parsed = JSON.parse(content.substring(jsonStart, jsonEnd + 1)); const parsed = JSON.parse(content.substring(jsonStart, jsonEnd + 1));
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
results = parsed.map((item: any) => { results = parsed.map((item: any) => {
const status = item.status === "flagged" ? "flagged" : item.status === "warn" ? "warn" : "clean"; const status =
item.status === "flagged"
? "flagged"
: item.status === "warn"
? "warn"
: "clean";
return { return {
status, status,
flags: Array.isArray(item.flags) ? item.flags.map(String) : [], flags: Array.isArray(item.flags) ? item.flags.map(String) : [],
score: Math.max(0, Math.min(1, Number(item.score) || 0)), score: Math.max(0, Math.min(1, Number(item.score) || 0)),
analysis: typeof item.analysis === "string" ? item.analysis : content, analysis:
typeof item.analysis === "string" ? item.analysis : content,
}; };
}); });
} }
@@ -213,10 +245,15 @@ Satu JSON object per pesan dalam array.`,
return { results, raw: response }; return { results, raw: response };
} }
async function analyzeAndStoreBatch(db: SqliteDatabase, messages: MessageRecord[]): Promise<void> { async function analyzeAndStoreBatch(
db: SqliteDatabase,
messages: MessageRecord[],
): Promise<void> {
if (messages.length === 0) return; if (messages.length === 0) return;
const analyzableMessages = messages.filter((message) => getAnalysisText(message).length > 0); const analyzableMessages = messages.filter(
(message) => getAnalysisText(message).length > 0,
);
if (analyzableMessages.length === 0) return; if (analyzableMessages.length === 0) return;
activeRequests++; activeRequests++;
@@ -228,7 +265,12 @@ async function analyzeAndStoreBatch(db: SqliteDatabase, messages: MessageRecord[
const result = results[i] || parseLLMAnalysis(""); const result = results[i] || parseLLMAnalysis("");
const row = updateMessageAIAnalysis(db, message.id, { const row = updateMessageAIAnalysis(db, message.id, {
status: result.status as "pending" | "clean" | "warn" | "flagged" | "error", status: result.status as
| "pending"
| "clean"
| "warn"
| "flagged"
| "error",
flags: JSON.stringify(result.flags), flags: JSON.stringify(result.flags),
score: result.score, score: result.score,
raw: JSON.stringify(raw), raw: JSON.stringify(raw),
@@ -242,7 +284,11 @@ async function analyzeAndStoreBatch(db: SqliteDatabase, messages: MessageRecord[
if (analyzableMessages.length > 1) { if (analyzableMessages.length > 1) {
const midpoint = Math.ceil(analyzableMessages.length / 2); const midpoint = Math.ceil(analyzableMessages.length / 2);
logger.warn( logger.warn(
{ count: analyzableMessages.length, nextBatchSizes: [midpoint, analyzableMessages.length - midpoint], error }, {
count: analyzableMessages.length,
nextBatchSizes: [midpoint, analyzableMessages.length - midpoint],
error,
},
"AI batch failed, splitting into smaller batches", "AI batch failed, splitting into smaller batches",
); );
await analyzeAndStoreBatch(db, analyzableMessages.slice(0, midpoint)); await analyzeAndStoreBatch(db, analyzableMessages.slice(0, midpoint));
@@ -288,7 +334,11 @@ async function drainQueue(db: SqliteDatabase): Promise<void> {
if (!message) continue; if (!message) continue;
const messageTokens = estimateMessageTokens(message); const messageTokens = estimateMessageTokens(message);
if (batch.length > 0 && (batch.length >= MAX_AI_BATCH_MESSAGES || tokenEstimate + messageTokens > batchTokenLimit)) { if (
batch.length > 0 &&
(batch.length >= MAX_AI_BATCH_MESSAGES ||
tokenEstimate + messageTokens > batchTokenLimit)
) {
queuedMessageIds.add(messageId); queuedMessageIds.add(messageId);
break; break;
} }
@@ -298,7 +348,10 @@ async function drainQueue(db: SqliteDatabase): Promise<void> {
} }
if (batch.length > 0) { if (batch.length > 0) {
logger.info({ count: batch.length, tokenEstimate }, "Processing AI analysis batch"); logger.info(
{ count: batch.length, tokenEstimate },
"Processing AI analysis batch",
);
await analyzeAndStoreBatch(db, batch); await analyzeAndStoreBatch(db, batch);
} }
} }
@@ -307,12 +360,17 @@ async function drainQueue(db: SqliteDatabase): Promise<void> {
} }
} }
export function queueMessageAnalysis(db: SqliteDatabase, messageId: string): void { export function queueMessageAnalysis(
db: SqliteDatabase,
messageId: string,
): void {
if (!config.AI_ANALYSIS_ENABLED) return; if (!config.AI_ANALYSIS_ENABLED) return;
logger.debug({ messageId }, "Queueing AI analysis"); logger.debug({ messageId }, "Queueing AI analysis");
queuedMessageIds.add(messageId); queuedMessageIds.add(messageId);
setImmediate(() => { setImmediate(() => {
drainQueue(db).catch((error) => logger.error({ error }, "AI analysis queue failed")); drainQueue(db).catch((error) =>
logger.error({ error }, "AI analysis queue failed"),
);
}); });
} }
@@ -327,10 +385,15 @@ export function startPendingAIAnalysisWorker(db: SqliteDatabase): void {
if (isProcessing) return; if (isProcessing) return;
const pendingMessages = getPendingAIAnalysisMessages(db, 500); const pendingMessages = getPendingAIAnalysisMessages(db, 500);
if (pendingMessages.length === 0) return; if (pendingMessages.length === 0) return;
logger.info({ count: pendingMessages.length }, "Queueing pending AI analysis messages"); logger.info(
{ count: pendingMessages.length },
"Queueing pending AI analysis messages",
);
for (const message of pendingMessages) { for (const message of pendingMessages) {
queuedMessageIds.add(message.id); queuedMessageIds.add(message.id);
} }
drainQueue(db).catch((error) => logger.error({ error }, "Pending AI analysis worker failed")); drainQueue(db).catch((error) =>
logger.error({ error }, "Pending AI analysis worker failed"),
);
}, 15000); }, 15000);
} }

View File

@@ -1,8 +1,11 @@
import { createChildLogger } from "../logger";
import { config } from "../config"; import { config } from "../config";
import { retryWithBackoff } from "../retry"; import { createChildLogger } from "../logger";
import type { SqliteDatabase } from "../muxer-queue"; import type { SqliteDatabase } from "../muxer-queue";
import { updateAttachmentAsUploaded, updateAttachmentAsFailedUpload } from "./messageStore"; import { retryWithBackoff } from "../retry";
import {
updateAttachmentAsFailedUpload,
updateAttachmentAsUploaded,
} from "./messageStore";
const logger = createChildLogger("attachment-uploader"); const logger = createChildLogger("attachment-uploader");
@@ -25,7 +28,9 @@ export interface ParsedUploadResponse {
type: string; type: string;
} }
export function parseUploadResponse(response: PicserUploadResponse): ParsedUploadResponse { export function parseUploadResponse(
response: PicserUploadResponse,
): ParsedUploadResponse {
if (!response.success) { if (!response.success) {
throw new Error("Upload failed: success=false"); throw new Error("Upload failed: success=false");
} }
@@ -49,7 +54,9 @@ export async function uploadAttachmentToPicser(
filename: string, filename: string,
): Promise<ParsedUploadResponse> { ): Promise<ParsedUploadResponse> {
const formData = new FormData(); const formData = new FormData();
const blob = new Blob([new Uint8Array(fileBuffer)], { type: "application/octet-stream" }); const blob = new Blob([new Uint8Array(fileBuffer)], {
type: "application/octet-stream",
});
formData.append("file", blob, filename); formData.append("file", blob, filename);
try { try {
@@ -76,11 +83,17 @@ export async function uploadAttachmentToPicser(
); );
const parsed = parseUploadResponse(response); const parsed = parseUploadResponse(response);
logger.info({ filename, url: parsed.url }, "Attachment uploaded successfully"); logger.info(
{ filename, url: parsed.url },
"Attachment uploaded successfully",
);
return parsed; return parsed;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ filename, error: error instanceof Error ? error.message : String(error) }, {
filename,
error: error instanceof Error ? error.message : String(error),
},
"Failed to upload attachment", "Failed to upload attachment",
); );
throw error; throw error;
@@ -121,13 +134,18 @@ export async function processAttachmentUpload(
const sizeMb = buffer.length / (1024 * 1024); const sizeMb = buffer.length / (1024 * 1024);
if (sizeMb > config.ATTACHMENT_MAX_SIZE_MB) { if (sizeMb > config.ATTACHMENT_MAX_SIZE_MB) {
throw new Error(`File size ${sizeMb.toFixed(2)}MB exceeds limit of ${config.ATTACHMENT_MAX_SIZE_MB}MB`); throw new Error(
`File size ${sizeMb.toFixed(2)}MB exceeds limit of ${config.ATTACHMENT_MAX_SIZE_MB}MB`,
);
} }
const result = await uploadAttachmentToPicser(buffer, filename); const result = await uploadAttachmentToPicser(buffer, filename);
updateAttachmentAsUploaded(db, attachmentId, result.url, Date.now()); updateAttachmentAsUploaded(db, attachmentId, result.url, Date.now());
logger.info({ attachmentId, uploadedUrl: result.url }, "Attachment upload completed"); logger.info(
{ attachmentId, uploadedUrl: result.url },
"Attachment upload completed",
);
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
updateAttachmentAsFailedUpload(db, attachmentId, errorMsg); updateAttachmentAsFailedUpload(db, attachmentId, errorMsg);

View File

@@ -53,11 +53,17 @@ export async function syncBacklogMessages(
const guild = client.guilds.cache.get(config.MONITOR_GUILD_ID); const guild = client.guilds.cache.get(config.MONITOR_GUILD_ID);
if (!guild) { if (!guild) {
logger.warn({ guildId: config.MONITOR_GUILD_ID }, "Monitor guild not found, skipping backlog sync"); logger.warn(
{ guildId: config.MONITOR_GUILD_ID },
"Monitor guild not found, skipping backlog sync",
);
return; return;
} }
logger.info({ guildId: guild.id }, "Backlog sync ready (will sync on-demand per selected channel)"); logger.info(
{ guildId: guild.id },
"Backlog sync ready (will sync on-demand per selected channel)",
);
} }
export async function syncSelectedChannelBacklog( export async function syncSelectedChannelBacklog(
@@ -86,7 +92,10 @@ export async function syncSelectedChannelBacklog(
try { try {
const count = await syncChannelMessages(db, channel as any, cutoffTime); const count = await syncChannelMessages(db, channel as any, cutoffTime);
logger.info({ channelId, count }, "Backlog sync completed for selected channel"); logger.info(
{ channelId, count },
"Backlog sync completed for selected channel",
);
return count; return count;
} catch (error) { } catch (error) {
logger.warn( logger.warn(

View File

@@ -1,11 +1,15 @@
import type { Client, Message } from "discord.js-selfbot-v13"; import type { Client, Message } from "discord.js-selfbot-v13";
import { createChildLogger } from "../logger";
import { config } from "../config"; import { config } from "../config";
import { createChildLogger } from "../logger";
import type { SqliteDatabase } from "../muxer-queue"; import type { SqliteDatabase } from "../muxer-queue";
import { insertMessage, insertAttachment } from "./messageStore";
import { getDisplayContent, getMessageLocation, getMessageMetadata } from "./messageMetadata";
import { queueMessageAnalysis } from "./aiAnalyzer"; import { queueMessageAnalysis } from "./aiAnalyzer";
import type { MessageRecord, AttachmentRecord } from "./types"; import {
getDisplayContent,
getMessageLocation,
getMessageMetadata,
} from "./messageMetadata";
import { insertAttachment, insertMessage } from "./messageStore";
import type { AttachmentRecord, MessageRecord } from "./types";
const logger = createChildLogger("message-capture"); const logger = createChildLogger("message-capture");
@@ -89,7 +93,10 @@ export async function captureMessage(
); );
} }
export function registerMessageCapture(client: Client, db: SqliteDatabase): void { export function registerMessageCapture(
client: Client,
db: SqliteDatabase,
): void {
client.on("messageCreate", async (message) => { client.on("messageCreate", async (message) => {
if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return; if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return;
if (message.author?.bot) return; if (message.author?.bot) return;
@@ -98,14 +105,18 @@ export function registerMessageCapture(client: Client, db: SqliteDatabase): void
await captureMessage(db, message, "text"); await captureMessage(db, message, "text");
} catch (error) { } catch (error) {
logger.error( logger.error(
{ messageId: message.id, error: error instanceof Error ? error.message : String(error) }, {
messageId: message.id,
error: error instanceof Error ? error.message : String(error),
},
"Failed to capture message", "Failed to capture message",
); );
} }
}); });
client.on("messageUpdate", async (_oldMessage, newMessage) => { client.on("messageUpdate", async (_oldMessage, newMessage) => {
if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID) return; if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID)
return;
if (newMessage.author?.bot) return; if (newMessage.author?.bot) return;
try { try {
@@ -117,7 +128,12 @@ export function registerMessageCapture(client: Client, db: SqliteDatabase): void
if (existing) { if (existing) {
const editedAt = Date.now(); const editedAt = Date.now();
updateMessageAsEdited(db, newMessage.id, getDisplayContent(newMessage as Message), editedAt); updateMessageAsEdited(
db,
newMessage.id,
getDisplayContent(newMessage as Message),
editedAt,
);
queueMessageAnalysis(db, newMessage.id); queueMessageAnalysis(db, newMessage.id);
const broadcaster = globalThis as any; const broadcaster = globalThis as any;
@@ -133,7 +149,10 @@ export function registerMessageCapture(client: Client, db: SqliteDatabase): void
} }
} catch (error) { } catch (error) {
logger.error( logger.error(
{ messageId: newMessage.id, error: error instanceof Error ? error.message : String(error) }, {
messageId: newMessage.id,
error: error instanceof Error ? error.message : String(error),
},
"Failed to capture message update", "Failed to capture message update",
); );
} }
@@ -159,7 +178,10 @@ export function registerMessageCapture(client: Client, db: SqliteDatabase): void
logger.info({ messageId: message.id }, "Message deletion captured"); logger.info({ messageId: message.id }, "Message deletion captured");
} catch (error) { } catch (error) {
logger.error( logger.error(
{ messageId: message.id, error: error instanceof Error ? error.message : String(error) }, {
messageId: message.id,
error: error instanceof Error ? error.message : String(error),
},
"Failed to capture message deletion", "Failed to capture message deletion",
); );
} }

View File

@@ -1,4 +1,8 @@
import type { Message, TextChannel, ThreadChannel } from "discord.js-selfbot-v13"; import type {
Message,
TextChannel,
ThreadChannel,
} from "discord.js-selfbot-v13";
export interface MessageLocation { export interface MessageLocation {
channelId: string; channelId: string;
@@ -8,7 +12,12 @@ export interface MessageLocation {
} }
export interface RichMessageMetadata { export interface RichMessageMetadata {
stickers: Array<{ id: string; name: string; url: string; format: string | null }>; stickers: Array<{
id: string;
name: string;
url: string;
format: string | null;
}>;
embeds: Array<{ embeds: Array<{
title: string | null; title: string | null;
description: string | null; description: string | null;
@@ -16,7 +25,11 @@ export interface RichMessageMetadata {
color: number | null; color: number | null;
image: string | null; image: string | null;
thumbnail: string | null; thumbnail: string | null;
author: { name: string | null; url: string | null; iconURL: string | null } | null; author: {
name: string | null;
url: string | null;
iconURL: string | null;
} | null;
footer: { text: string | null; iconURL: string | null } | null; footer: { text: string | null; iconURL: string | null } | null;
fields: Array<{ name: string; value: string; inline: boolean }>; fields: Array<{ name: string; value: string; inline: boolean }>;
}>; }>;
@@ -66,7 +79,9 @@ export function getMessageLocation(message: Message): MessageLocation {
}; };
} }
export function getStickerMetadata(message: Message): RichMessageMetadata["stickers"] { export function getStickerMetadata(
message: Message,
): RichMessageMetadata["stickers"] {
return Array.from(message.stickers.values()).map((sticker) => ({ return Array.from(message.stickers.values()).map((sticker) => ({
id: sticker.id, id: sticker.id,
name: sticker.name, name: sticker.name,
@@ -75,7 +90,9 @@ export function getStickerMetadata(message: Message): RichMessageMetadata["stick
})); }));
} }
export function getAttachmentMetadata(message: Message): RichMessageMetadata["attachments"] { export function getAttachmentMetadata(
message: Message,
): RichMessageMetadata["attachments"] {
return Array.from(message.attachments.values()).map((attachment) => ({ return Array.from(message.attachments.values()).map((attachment) => ({
id: attachment.id, id: attachment.id,
name: attachment.name || "unknown", name: attachment.name || "unknown",
@@ -85,7 +102,9 @@ export function getAttachmentMetadata(message: Message): RichMessageMetadata["at
})); }));
} }
export function getEmbedMetadata(message: Message): RichMessageMetadata["embeds"] { export function getEmbedMetadata(
message: Message,
): RichMessageMetadata["embeds"] {
return message.embeds.map((embed) => ({ return message.embeds.map((embed) => ({
title: embed.title ?? null, title: embed.title ?? null,
description: embed.description ?? null, description: embed.description ?? null,
@@ -130,7 +149,10 @@ export function getMessageMetadata(message: Message): RichMessageMetadata {
member: member member: member
? { ? {
displayName: member.displayName ?? null, displayName: member.displayName ?? null,
roles: member.roles.cache.map((role) => ({ id: role.id, name: role.name })), roles: member.roles.cache.map((role) => ({
id: role.id,
name: role.name,
})),
joinedTimestamp: member.joinedTimestamp ?? null, joinedTimestamp: member.joinedTimestamp ?? null,
} }
: null, : null,
@@ -155,12 +177,16 @@ export function getDisplayContent(message: Message): string {
const attachments = getAttachmentMetadata(message); const attachments = getAttachmentMetadata(message);
if (attachments.length > 0) { if (attachments.length > 0) {
return attachments.map((attachment) => `[Attachment: ${attachment.name}]`).join(" "); return attachments
.map((attachment) => `[Attachment: ${attachment.name}]`)
.join(" ");
} }
const embeds = getEmbedMetadata(message); const embeds = getEmbedMetadata(message);
if (embeds.length > 0) { if (embeds.length > 0) {
return embeds.map((embed) => embed.title || embed.description || "[Embed]").join(" "); return embeds
.map((embed) => embed.title || embed.description || "[Embed]")
.join(" ");
} }
return ""; return "";

View File

@@ -1,10 +1,13 @@
import { createChildLogger } from "../logger";
import type { DatabaseAdapter } from "../database/adapter"; import type { DatabaseAdapter } from "../database/adapter";
import type { MessageRecord, AttachmentRecord } from "./types"; import { createChildLogger } from "../logger";
import type { AttachmentRecord, MessageRecord } from "./types";
const logger = createChildLogger("message-store"); const logger = createChildLogger("message-store");
export function insertMessage(db: DatabaseAdapter, message: MessageRecord): void { export function insertMessage(
db: DatabaseAdapter,
message: MessageRecord,
): void {
try { try {
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT OR IGNORE INTO messages ( INSERT OR IGNORE INTO messages (
@@ -30,10 +33,16 @@ export function insertMessage(db: DatabaseAdapter, message: MessageRecord): void
message.metadata, message.metadata,
); );
logger.debug({ messageId: message.id, channelId: message.channel_id }, "Message inserted"); logger.debug(
{ messageId: message.id, channelId: message.channel_id },
"Message inserted",
);
} catch (error) { } catch (error) {
logger.error( logger.error(
{ messageId: message.id, error: error instanceof Error ? error.message : String(error) }, {
messageId: message.id,
error: error instanceof Error ? error.message : String(error),
},
"Failed to insert message", "Failed to insert message",
); );
throw error; throw error;
@@ -57,7 +66,10 @@ export function updateMessageAsEdited(
logger.debug({ messageId }, "Message marked as edited"); logger.debug({ messageId }, "Message marked as edited");
} catch (error) { } catch (error) {
logger.error( logger.error(
{ messageId, error: error instanceof Error ? error.message : String(error) }, {
messageId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to update message as edited", "Failed to update message as edited",
); );
throw error; throw error;
@@ -80,7 +92,10 @@ export function updateMessageAsDeleted(
logger.debug({ messageId }, "Message marked as deleted"); logger.debug({ messageId }, "Message marked as deleted");
} catch (error) { } catch (error) {
logger.error( logger.error(
{ messageId, error: error instanceof Error ? error.message : String(error) }, {
messageId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to update message as deleted", "Failed to update message as deleted",
); );
throw error; throw error;
@@ -101,18 +116,29 @@ export function getMessagesByChannel(
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`); `);
const rows = stmt.all(channelId, channelId, limit, offset) as MessageRecord[]; const rows = stmt.all(
channelId,
channelId,
limit,
offset,
) as MessageRecord[];
return rows; return rows;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ channelId, error: error instanceof Error ? error.message : String(error) }, {
channelId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to get messages by channel", "Failed to get messages by channel",
); );
throw error; throw error;
} }
} }
export function insertAttachment(db: DatabaseAdapter, attachment: AttachmentRecord): void { export function insertAttachment(
db: DatabaseAdapter,
attachment: AttachmentRecord,
): void {
try { try {
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT OR IGNORE INTO attachments ( INSERT OR IGNORE INTO attachments (
@@ -139,10 +165,16 @@ export function insertAttachment(db: DatabaseAdapter, attachment: AttachmentReco
attachment.uploaded_at, attachment.uploaded_at,
); );
logger.debug({ attachmentId: attachment.id, messageId: attachment.message_id }, "Attachment inserted"); logger.debug(
{ attachmentId: attachment.id, messageId: attachment.message_id },
"Attachment inserted",
);
} catch (error) { } catch (error) {
logger.error( logger.error(
{ attachmentId: attachment.id, error: error instanceof Error ? error.message : String(error) }, {
attachmentId: attachment.id,
error: error instanceof Error ? error.message : String(error),
},
"Failed to insert attachment", "Failed to insert attachment",
); );
throw error; throw error;
@@ -163,11 +195,19 @@ export function getAttachmentsByChannel(
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`); `);
const rows = stmt.all(channelId, channelId, limit, offset) as AttachmentRecord[]; const rows = stmt.all(
channelId,
channelId,
limit,
offset,
) as AttachmentRecord[];
return rows; return rows;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ channelId, error: error instanceof Error ? error.message : String(error) }, {
channelId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to get attachments by channel", "Failed to get attachments by channel",
); );
throw error; throw error;
@@ -188,10 +228,16 @@ export function updateAttachmentAsUploaded(
`); `);
stmt.run(uploadedUrl, uploadedAt, attachmentId); stmt.run(uploadedUrl, uploadedAt, attachmentId);
logger.debug({ attachmentId, uploadedUrl }, "Attachment marked as uploaded"); logger.debug(
{ attachmentId, uploadedUrl },
"Attachment marked as uploaded",
);
} catch (error) { } catch (error) {
logger.error( logger.error(
{ attachmentId, error: error instanceof Error ? error.message : String(error) }, {
attachmentId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to update attachment as uploaded", "Failed to update attachment as uploaded",
); );
throw error; throw error;
@@ -214,7 +260,10 @@ export function updateAttachmentAsFailedUpload(
logger.debug({ attachmentId, error }, "Attachment marked as failed upload"); logger.debug({ attachmentId, error }, "Attachment marked as failed upload");
} catch (error) { } catch (error) {
logger.error( logger.error(
{ attachmentId, error: error instanceof Error ? error.message : String(error) }, {
attachmentId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to update attachment as failed", "Failed to update attachment as failed",
); );
throw error; throw error;
@@ -255,11 +304,16 @@ export function updateMessageAIAnalysis(
messageId, messageId,
); );
const row = db.prepare("SELECT * FROM messages WHERE id = ?").get(messageId) as MessageRecord | undefined; const row = db
.prepare("SELECT * FROM messages WHERE id = ?")
.get(messageId) as MessageRecord | undefined;
return row ?? null; return row ?? null;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ messageId, error: error instanceof Error ? error.message : String(error) }, {
messageId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to update message AI analysis", "Failed to update message AI analysis",
); );
throw error; throw error;
@@ -289,7 +343,12 @@ export function getPendingAIAnalysisMessages(
} }
} }
export function getMessageById(db: DatabaseAdapter, messageId: string): MessageRecord | null { export function getMessageById(
const row = db.prepare("SELECT * FROM messages WHERE id = ?").get(messageId) as MessageRecord | undefined; db: DatabaseAdapter,
messageId: string,
): MessageRecord | null {
const row = db
.prepare("SELECT * FROM messages WHERE id = ?")
.get(messageId) as MessageRecord | undefined;
return row ?? null; return row ?? null;
} }

View File

@@ -1,4 +1,7 @@
import { getDatabase as getDatabaseAdapter, DatabaseAdapter } from "./database/adapter"; import {
DatabaseAdapter,
getDatabase as getDatabaseAdapter,
} from "./database/adapter";
import { createChildLogger } from "./logger"; import { createChildLogger } from "./logger";
const logger = createChildLogger("muxer-queue"); const logger = createChildLogger("muxer-queue");
@@ -137,7 +140,10 @@ async function getDatabaseAdapterInternal(): Promise<DatabaseAdapter> {
// Export as getDatabase for backward compatibility // Export as getDatabase for backward compatibility
export const getDatabase = getDatabaseAdapterInternal; export const getDatabase = getDatabaseAdapterInternal;
export async function getPersistedValue<T>(key: string, fallback: T): Promise<T> { export async function getPersistedValue<T>(
key: string,
fallback: T,
): Promise<T> {
const adapter = await getDatabaseAdapterInternal(); const adapter = await getDatabaseAdapterInternal();
const row = adapter const row = adapter
.prepare("SELECT value FROM ui_state WHERE key = ?") .prepare("SELECT value FROM ui_state WHERE key = ?")
@@ -150,7 +156,10 @@ export async function getPersistedValue<T>(key: string, fallback: T): Promise<T>
} }
} }
export async function setPersistedValue(key: string, value: unknown): Promise<void> { export async function setPersistedValue(
key: string,
value: unknown,
): Promise<void> {
const adapter = await getDatabaseAdapterInternal(); const adapter = await getDatabaseAdapterInternal();
adapter adapter
.prepare(` .prepare(`

View File

@@ -90,12 +90,19 @@ export class VoiceController {
const threads: ChannelSummary[] = []; const threads: ChannelSummary[] = [];
for (const channel of guild.channels.cache.values()) { for (const channel of guild.channels.cache.values()) {
const threadParent = channel as typeof channel & { const threadParent = channel as typeof channel & {
threads?: { fetch: (options: { archived: boolean; limit: number }) => Promise<any> }; threads?: {
fetch: (options: {
archived: boolean;
limit: number;
}) => Promise<any>;
};
}; };
if (!threadParent.threads?.fetch) continue; if (!threadParent.threads?.fetch) continue;
for (const archived of [false, true]) { for (const archived of [false, true]) {
const fetched = await threadParent.threads.fetch({ archived, limit: 100 }).catch(() => null); const fetched = await threadParent.threads
.fetch({ archived, limit: 100 })
.catch(() => null);
if (!fetched?.threads) continue; if (!fetched?.threads) continue;
for (const thread of fetched.threads.values()) { for (const thread of fetched.threads.values()) {
@@ -108,8 +115,9 @@ export class VoiceController {
} }
} }
return Array.from(new Map(threads.map((thread) => [thread.id, thread])).values()) return Array.from(
.sort((a, b) => a.name.localeCompare(b.name)); new Map(threads.map((thread) => [thread.id, thread])).values(),
).sort((a, b) => a.name.localeCompare(b.name));
} }
async connect(guildId: string, channelId: string): Promise<VoiceStatus> { async connect(guildId: string, channelId: string): Promise<VoiceStatus> {

View File

@@ -8,11 +8,18 @@ import { WebSocketServer } from "ws";
import { AppError } from "./errors"; import { AppError } from "./errors";
import { createChildLogger, logger } from "./logger"; import { createChildLogger, logger } from "./logger";
import { getMetrics, uptimeGauge } from "./metrics"; import { getMetrics, uptimeGauge } from "./metrics";
import { syncSelectedChannelBacklog } from "./moderation/backlogSync";
import {
getAttachmentsByChannel,
getMessagesByChannel,
} from "./moderation/messageStore";
import {
getDatabase,
getPersistedValue,
setPersistedValue,
} from "./muxer-queue";
import { discordPlayer } from "./player"; import { discordPlayer } from "./player";
import type { VoiceController } from "./voiceController"; import type { VoiceController } from "./voiceController";
import { getDatabase, getPersistedValue, setPersistedValue } from "./muxer-queue";
import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore";
import { syncSelectedChannelBacklog } from "./moderation/backlogSync";
const wsLogger = createChildLogger("webserver"); const wsLogger = createChildLogger("webserver");
@@ -139,7 +146,11 @@ export async function startWebserver(
if (req.originalUrl === "/favicon.ico") return; if (req.originalUrl === "/favicon.ico") return;
if (res.statusCode >= 400) { if (res.statusCode >= 400) {
logger.error( logger.error(
{ method: req.method, url: req.originalUrl, statusCode: res.statusCode }, {
method: req.method,
url: req.originalUrl,
statusCode: res.statusCode,
},
"HTTP request failed", "HTTP request failed",
); );
} }
@@ -250,7 +261,12 @@ export async function startWebserver(
app.get("/api/messages", async (req, res, next) => { app.get("/api/messages", async (req, res, next) => {
try { try {
const db = await getDatabase(); const db = await getDatabase();
const { channel, type, limit = "50", offset = "0" } = req.query as { const {
channel,
type,
limit = "50",
offset = "0",
} = req.query as {
channel?: string; channel?: string;
type?: string; type?: string;
limit?: string; limit?: string;
@@ -258,14 +274,23 @@ export async function startWebserver(
}; };
if (!channel) { if (!channel) {
throw new AppError("channel query parameter is required", "MISSING_CHANNEL", 400); throw new AppError(
"channel query parameter is required",
"MISSING_CHANNEL",
400,
);
} }
const limitNum = Math.min(parseInt(limit) || 50, 100); const limitNum = Math.min(parseInt(limit) || 50, 100);
const offsetNum = parseInt(offset) || 0; const offsetNum = parseInt(offset) || 0;
if (type === "image") { if (type === "image") {
const attachments = getAttachmentsByChannel(db, channel, limitNum, offsetNum); const attachments = getAttachmentsByChannel(
db,
channel,
limitNum,
offsetNum,
);
res.json({ res.json({
type: "image", type: "image",
data: attachments, data: attachments,
@@ -299,7 +324,12 @@ export async function startWebserver(
); );
} }
const count = await syncSelectedChannelBacklog(_client, await getDatabase(), guildId, channelId); const count = await syncSelectedChannelBacklog(
_client,
await getDatabase(),
guildId,
channelId,
);
res.json({ res.json({
success: true, success: true,
channelId, channelId,

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
beforeEach(() => { beforeEach(() => {
process.env = { process.env = {
@@ -11,13 +11,16 @@ beforeEach(() => {
describe("attachmentUploader", () => { describe("attachmentUploader", () => {
it("parses picser upload response correctly", async () => { it("parses picser upload response correctly", async () => {
const { parseUploadResponse } = await import("../../src/moderation/attachmentUploader"); const { parseUploadResponse } = await import(
"../../src/moderation/attachmentUploader"
);
const response = { const response = {
success: true, success: true,
filename: "uploads/abc123.jpg", filename: "uploads/abc123.jpg",
urls: { urls: {
raw_commit: "https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg", raw_commit:
"https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg",
}, },
size: 102400, size: 102400,
type: "image/jpeg", type: "image/jpeg",
@@ -26,12 +29,16 @@ describe("attachmentUploader", () => {
const result = parseUploadResponse(response); const result = parseUploadResponse(response);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.url).toBe("https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg"); expect(result.url).toBe(
"https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg",
);
expect(result.filename).toBe("uploads/abc123.jpg"); expect(result.filename).toBe("uploads/abc123.jpg");
}); });
it("handles upload response with missing raw_commit", async () => { it("handles upload response with missing raw_commit", async () => {
const { parseUploadResponse } = await import("../../src/moderation/attachmentUploader"); const { parseUploadResponse } = await import(
"../../src/moderation/attachmentUploader"
);
const response = { const response = {
success: true, success: true,