fix: organize imports and apply linting fixes
This commit is contained in:
@@ -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,7 +91,8 @@ 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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
20
src/index.ts
20
src/index.ts
@@ -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");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(`
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user