refactor: large codebase cleanup - consolidate schemas, migrate to Drizzle ORM, extract frontend components, modernize Docker builds
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 2m22s
Build & Deploy / build-and-push (backend) (push) Failing after 3m22s
Build & Deploy / build-and-push (proxy) (push) Successful in 1m36s
Build & Deploy / deploy (push) Skipped
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 2m22s
Build & Deploy / build-and-push (backend) (push) Failing after 3m22s
Build & Deploy / build-and-push (proxy) (push) Successful in 1m36s
Build & Deploy / deploy (push) Skipped
- Consolidate all DB schema definitions into packages/shared as single source of truth - Migrate backend from raw SQL to Drizzle ORM across all modules - Extract frontend inline UI into separate component files - Refactor discord-gateway circuitBreaker into conversationState + moderationState - Convert messageStore to Proxy singleton pattern - Add validateBody/validateQuery middleware + Zod schemas for API endpoints - Modernize Docker builds with multi-stage + pnpm deploy - Migrate CI/CD from deployment to image-based pipeline - Remove 60+ unused/dead files (~15K lines) - Update color scheme from sky-blue to teal-cyan - Move DB connection management to @bete/shared/database Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
63f21513bd
commit
5802d02e29
@@ -1,24 +1,13 @@
|
||||
import "dotenv/config";
|
||||
import type { AppConfig as SharedAppConfig } from "@bete/shared/config";
|
||||
import type { AppConfig } from "@bete/shared/config";
|
||||
import { loadConfig as sharedLoadConfig } from "@bete/shared/config";
|
||||
|
||||
// Re-export the unified config with EFFECTIVE_* fields added
|
||||
export type AppConfig = SharedAppConfig & {
|
||||
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||
EFFECTIVE_VOICE_GUILD_ID?: string;
|
||||
EFFECTIVE_MONITOR_GUILD_IDS: string[];
|
||||
};
|
||||
// Re-export the unified config — all EFFECTIVE_* fields are already
|
||||
// computed by the shared loadConfig().
|
||||
export type { AppConfig };
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const parsed = sharedLoadConfig(env);
|
||||
return {
|
||||
...parsed,
|
||||
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
||||
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID,
|
||||
EFFECTIVE_MONITOR_GUILD_IDS:
|
||||
(parsed as any).EFFECTIVE_MONITOR_GUILD_IDS ??
|
||||
(parsed.MONITOR_GUILD_ID ? [parsed.MONITOR_GUILD_ID] : []),
|
||||
};
|
||||
return sharedLoadConfig(env);
|
||||
}
|
||||
|
||||
export const config = loadConfig();
|
||||
|
||||
@@ -1,129 +1,38 @@
|
||||
import {
|
||||
closeDatabase as sharedCloseDb,
|
||||
executeAll as sharedExecAll,
|
||||
executeGet as sharedExecGet,
|
||||
getDatabase as sharedGetDb,
|
||||
initializeDatabase as sharedInit,
|
||||
withDatabaseClient as sharedWithClient,
|
||||
} from "@bete/shared/database/init";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import type { PoolClient } from "pg";
|
||||
import { Pool } from "pg";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
const logger = createChildLogger("drizzle");
|
||||
|
||||
let db: ReturnType<typeof drizzlePostgres> | null = null;
|
||||
let rawPool: Pool | null = null;
|
||||
const dbConfig = {
|
||||
DATABASE_URL: config.DATABASE_URL,
|
||||
POSTGRES_HOST: config.POSTGRES_HOST,
|
||||
POSTGRES_PORT: config.POSTGRES_PORT,
|
||||
POSTGRES_USER: config.POSTGRES_USER,
|
||||
POSTGRES_PASSWORD: config.POSTGRES_PASSWORD,
|
||||
POSTGRES_DB: config.POSTGRES_DB,
|
||||
POSTGRES_POOL_MIN: config.POSTGRES_POOL_MIN,
|
||||
POSTGRES_POOL_MAX: config.POSTGRES_POOL_MAX,
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the PostgreSQL database connection.
|
||||
*/
|
||||
export async function initializeDatabase() {
|
||||
if (db !== null) {
|
||||
return db;
|
||||
}
|
||||
|
||||
let pool: Pool;
|
||||
|
||||
if (config.DATABASE_URL) {
|
||||
pool = new Pool({
|
||||
connectionString: config.DATABASE_URL,
|
||||
min: config.POSTGRES_POOL_MIN,
|
||||
max: config.POSTGRES_POOL_MAX,
|
||||
});
|
||||
} else {
|
||||
pool = new Pool({
|
||||
host: config.POSTGRES_HOST,
|
||||
port: config.POSTGRES_PORT,
|
||||
user: config.POSTGRES_USER,
|
||||
password: config.POSTGRES_PASSWORD,
|
||||
database: config.POSTGRES_DB,
|
||||
min: config.POSTGRES_POOL_MIN,
|
||||
max: config.POSTGRES_POOL_MAX,
|
||||
});
|
||||
}
|
||||
|
||||
rawPool = pool;
|
||||
db = drizzlePostgres(pool, { schema });
|
||||
|
||||
try {
|
||||
(db as { run?: (sql: string) => Promise<unknown> }).run = (sql: string) =>
|
||||
pool.query(sql);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
logger.info("PostgreSQL database initialized");
|
||||
return db;
|
||||
logger.info("Initializing database");
|
||||
return sharedInit(dbConfig, schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the initialized database instance.
|
||||
* Throws if database has not been initialized.
|
||||
*/
|
||||
export function getDatabase() {
|
||||
if (db === null) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
return db;
|
||||
return sharedGetDb();
|
||||
}
|
||||
|
||||
function convertPlaceholdersForPostgres(sql: string) {
|
||||
let i = 0;
|
||||
return sql.replace(/\?/g, () => `$${++i}`);
|
||||
}
|
||||
|
||||
export async function executeAll(sql: string, params?: unknown[]) {
|
||||
if (!rawPool) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
|
||||
const query = convertPlaceholdersForPostgres(sql);
|
||||
const result = await rawPool.query(query, params || []);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function executeGet(sql: string, params?: unknown[]) {
|
||||
if (!rawPool) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
|
||||
const query = convertPlaceholdersForPostgres(sql);
|
||||
const result = await rawPool.query(query, params || []);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a function with a dedicated PostgreSQL client from the shared pool.
|
||||
* Use this for session-scoped operations such as advisory locks.
|
||||
*/
|
||||
export async function withDatabaseClient<T>(
|
||||
callback: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!rawPool) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
|
||||
const client = await rawPool.connect();
|
||||
try {
|
||||
return await callback(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the PostgreSQL connection pool.
|
||||
*/
|
||||
export async function closeDatabase() {
|
||||
if (rawPool !== null) {
|
||||
await rawPool.end();
|
||||
}
|
||||
|
||||
rawPool = null;
|
||||
db = null;
|
||||
logger.info("PostgreSQL database closed");
|
||||
}
|
||||
export const closeDatabase = sharedCloseDb;
|
||||
export const executeAll = sharedExecAll;
|
||||
export const executeGet = sharedExecGet;
|
||||
export const withDatabaseClient = sharedWithClient;
|
||||
|
||||
@@ -1,522 +1,13 @@
|
||||
import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared";
|
||||
import {
|
||||
bigint as pgBigint,
|
||||
boolean as pgBoolean,
|
||||
index as pgIndex,
|
||||
integer as pgInteger,
|
||||
jsonb as pgJsonb,
|
||||
pgTable,
|
||||
text as pgText,
|
||||
timestamp as pgTimestamp,
|
||||
uuid as pgUuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database Schema — barrel re-export
|
||||
//
|
||||
// All table definitions have been split into domain files under schema/.
|
||||
// This barrel preserves backward compatibility for existing imports.
|
||||
// New code can import from the specific domain file (e.g., schema/messages.js).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PostgreSQL Schema
|
||||
// ==================
|
||||
|
||||
/**
|
||||
* Muxer Jobs Table (PostgreSQL)
|
||||
* Tracks audio post-processing jobs with status and retry logic
|
||||
*/
|
||||
export const pgMuxerJobsTable = pgTable(
|
||||
"muxer_jobs",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
data: pgText("data").notNull(),
|
||||
status: pgText("status", {
|
||||
enum: ["pending", "processing", "completed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
attempts: pgInteger("attempts").notNull().default(0),
|
||||
maxAttempts: pgInteger("maxAttempts").notNull().default(3),
|
||||
createdAt: pgBigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: pgBigint("updatedAt", { mode: "number" }).notNull(),
|
||||
error: pgText("error"),
|
||||
},
|
||||
(table) => ({
|
||||
statusIdx: pgIndex("idx_muxer_jobs_status").on(table.status),
|
||||
createdAtIdx: pgIndex("idx_muxer_jobs_createdAt").on(table.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
// (pgMessagesTable and pgAttachmentsTable are imported from @bete/shared)
|
||||
|
||||
/**
|
||||
* UI State Table (PostgreSQL)
|
||||
* Stores persistent UI state (e.g., selected channel, filter preferences)
|
||||
*/
|
||||
export const pgUIStateTable = pgTable("ui_state", {
|
||||
key: pgText("key").primaryKey(),
|
||||
value: pgText("value").notNull(),
|
||||
updated_at: pgBigint("updated_at", { mode: "number" }).notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* AI Analysis Runs Table (PostgreSQL)
|
||||
* Tracks AI analysis batch runs for conversation-level moderation
|
||||
*/
|
||||
export const pgAIAnalysisRunsTable = pgTable(
|
||||
"ai_analysis_runs",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
conversation_key: pgText("conversation_key").notNull(),
|
||||
target_message_ids: pgText("target_message_ids").notNull(), // JSON array
|
||||
model: pgText("model").notNull(),
|
||||
request_tokens_estimate: pgInteger("request_tokens_estimate"),
|
||||
response_raw: pgText("response_raw"),
|
||||
status: pgText("status", {
|
||||
enum: ["pending", "processing", "completed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
error: pgText("error"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
completed_at: pgBigint("completed_at", { mode: "number" }),
|
||||
},
|
||||
(table) => ({
|
||||
conversationKeyIdx: pgIndex("idx_ai_analysis_runs_conversation_key").on(
|
||||
table.conversation_key,
|
||||
),
|
||||
statusIdx: pgIndex("idx_ai_analysis_runs_status").on(table.status),
|
||||
createdAtIdx: pgIndex("idx_ai_analysis_runs_created_at").on(
|
||||
table.created_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Voice Recordings Table (PostgreSQL)
|
||||
* Stores voice recording segment metadata and upload status
|
||||
*/
|
||||
export const pgVoiceRecordingsTable = pgTable(
|
||||
"voice_recordings",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
user_id: pgText("user_id").notNull(),
|
||||
username: pgText("username").notNull(),
|
||||
avatar_url: pgText("avatar_url"),
|
||||
guild_id: pgText("guild_id"),
|
||||
channel_id: pgText("channel_id"),
|
||||
channel_name: pgText("channel_name"),
|
||||
filename: pgText("filename").notNull(),
|
||||
size_bytes: pgInteger("size_bytes").notNull(),
|
||||
download_url: pgText("download_url"),
|
||||
upload_status: pgText("upload_status", {
|
||||
enum: ["pending", "uploaded", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
upload_error: pgText("upload_error"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
uploaded_at: pgBigint("uploaded_at", { mode: "number" }),
|
||||
transcription: pgText("transcription"),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: pgIndex("idx_voice_recordings_user_id").on(table.user_id),
|
||||
channelIdIdx: pgIndex("idx_voice_recordings_channel_id").on(
|
||||
table.channel_id,
|
||||
),
|
||||
createdIdx: pgIndex("idx_voice_recordings_created_at").on(table.created_at),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* User Reputations Table (PostgreSQL)
|
||||
* Tracks user trust score and infractions to provide context to AI.
|
||||
*/
|
||||
export const pgUserReputationsTable = pgTable(
|
||||
"user_reputations",
|
||||
{
|
||||
user_id: pgText("user_id").primaryKey(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
trust_score: pgInteger("trust_score").notNull().default(50),
|
||||
clean_message_streak: pgInteger("clean_message_streak")
|
||||
.notNull()
|
||||
.default(0),
|
||||
total_infractions: pgInteger("total_infractions").notNull().default(0),
|
||||
last_infraction_at: pgBigint("last_infraction_at", { mode: "number" }),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
updated_at: pgBigint("updated_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
guildIdx: pgIndex("idx_user_reputations_guild_id").on(table.guild_id),
|
||||
scoreIdx: pgIndex("idx_user_reputations_trust_score").on(table.trust_score),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Channel Cultures Table (PostgreSQL)
|
||||
* Stores AI-generated summaries of channel norms and slang to inject as context.
|
||||
*/
|
||||
export const pgChannelCulturesTable = pgTable(
|
||||
"channel_cultures",
|
||||
{
|
||||
channel_id: pgText("channel_id").primaryKey(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
culture_summary: pgText("culture_summary").notNull(),
|
||||
last_analyzed_at: pgBigint("last_analyzed_at", {
|
||||
mode: "number",
|
||||
}).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
guildIdx: pgIndex("idx_channel_cultures_guild_id").on(table.guild_id),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Message Reviews Table (PostgreSQL)
|
||||
* Tracks manual reviews of messages flagged by AI moderation
|
||||
*/
|
||||
export const pgMessageReviewsTable = pgTable(
|
||||
"message_reviews",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
message_id: pgText("message_id").notNull(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
channel_id: pgText("channel_id").notNull(),
|
||||
reviewer_id: pgText("reviewer_id"),
|
||||
status: pgText("status", {
|
||||
enum: ["pending", "approved", "rejected", "escalated"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
notes: pgText("notes"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
reviewed_at: pgBigint("reviewed_at", { mode: "number" }),
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_message_reviews_message_id").on(
|
||||
table.message_id,
|
||||
),
|
||||
statusIdx: pgIndex("idx_message_reviews_status").on(table.status),
|
||||
createdAtIdx: pgIndex("idx_message_reviews_created_at").on(
|
||||
table.created_at,
|
||||
),
|
||||
guildStatusIdx: pgIndex("idx_message_reviews_guild_status").on(
|
||||
table.guild_id,
|
||||
table.status,
|
||||
table.created_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Moderation Actions Table (PostgreSQL)
|
||||
* Tracks actions taken on messages (delete, mute, etc.)
|
||||
*/
|
||||
export const pgModerationActionsTable = pgTable(
|
||||
"moderation_actions",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
message_id: pgText("message_id"),
|
||||
user_id: pgText("user_id"),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
action_type: pgText("action_type", {
|
||||
enum: [
|
||||
"delete_message",
|
||||
"mute_user",
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
],
|
||||
}).notNull(),
|
||||
reason: pgText("reason"),
|
||||
executed_by: pgText("executed_by"),
|
||||
status: pgText("status", {
|
||||
enum: ["pending", "executed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
error: pgText("error"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
executed_at: pgBigint("executed_at", { mode: "number" }),
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
||||
table.message_id,
|
||||
),
|
||||
userIdIdx: pgIndex("idx_moderation_actions_user_id").on(table.user_id),
|
||||
statusIdx: pgIndex("idx_moderation_actions_status").on(table.status),
|
||||
guildStatusIdx: pgIndex("idx_moderation_actions_guild_status").on(
|
||||
table.guild_id,
|
||||
table.status,
|
||||
table.created_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Retention Policies Table (PostgreSQL)
|
||||
* Defines data retention rules per guild/channel
|
||||
*/
|
||||
export const pgRetentionPoliciesTable = pgTable(
|
||||
"retention_policies",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
channel_id: pgText("channel_id"),
|
||||
retention_days: pgInteger("retention_days").notNull().default(90),
|
||||
apply_to_media: pgBoolean("apply_to_media").notNull().default(true),
|
||||
apply_to_voice: pgBoolean("apply_to_voice").notNull().default(true),
|
||||
enabled: pgBoolean("enabled").notNull().default(true),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
updated_at: pgBigint("updated_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
guildIdIdx: pgIndex("idx_retention_policies_guild_id").on(table.guild_id),
|
||||
enabledIdx: pgIndex("idx_retention_policies_enabled").on(table.enabled),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Text Analysis Cache Table (PostgreSQL)
|
||||
* Caches per-normalized-text moderation analysis results so repeated
|
||||
* phrases reuse previously computed API / fallback results instead of
|
||||
* re-calling expensive LLM or external moderation APIs.
|
||||
*
|
||||
* Uses the FULL normalized text (not per-word) because context matters:
|
||||
* "kau" alone is clean, but "awas kau" can be a threat.
|
||||
*/
|
||||
export const pgTextAnalysisCacheTable = pgTable(
|
||||
"text_analysis_cache",
|
||||
{
|
||||
/** Normalized text (lowercase, whitespace-collapsed) — primary key. */
|
||||
text: pgText("text").primaryKey(),
|
||||
/** JSON array of moderation flags detected for this text (e.g. ["vulgar_language","harassment"]). */
|
||||
flags: pgText("flags").notNull().default("[]"),
|
||||
/** Which source produced this result: "local" | "primary_ai" | "vision_llm". */
|
||||
source: pgText("source", {
|
||||
enum: ["local", "primary_ai", "vision_llm"],
|
||||
})
|
||||
.notNull()
|
||||
.default("local"),
|
||||
/** Epoch millis when the analysis was stored. */
|
||||
analyzed_at: pgBigint("analyzed_at", { mode: "number" }).notNull(),
|
||||
/** Epoch millis when this cache entry expires. */
|
||||
expires_at: pgBigint("expires_at", { mode: "number" }).notNull(),
|
||||
/** How many times this cached text has been reused. */
|
||||
hit_count: pgInteger("hit_count").notNull().default(0),
|
||||
},
|
||||
(table) => ({
|
||||
expiresAtIdx: pgIndex("idx_text_analysis_cache_expires_at").on(
|
||||
table.expires_at,
|
||||
),
|
||||
sourceIdx: pgIndex("idx_text_analysis_cache_source").on(table.source),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Sticker Cache Table (PostgreSQL)
|
||||
*
|
||||
* Stores uploaded sticker image URLs instead of raw base64 blobs.
|
||||
* Stickers are uploaded to the external upload service once and the URL is
|
||||
* cached here so subsequent occurrences reuse the same URL for vision analysis.
|
||||
*
|
||||
* TTL: 7 days (enforced at query time via fetched_at)
|
||||
* Eviction: max 5000 entries (LRU by fetched_at)
|
||||
*/
|
||||
export const pgStickerCacheTable = pgTable(
|
||||
"sticker_cache",
|
||||
{
|
||||
/** Sanitized sticker name (encodeURIComponent + %→_) — primary key. */
|
||||
name: pgText("name").primaryKey(),
|
||||
/** Uploaded image URL (tele/picser). Used directly as image_url in vision API. */
|
||||
imageUrl: pgText("image_url").notNull().default(""),
|
||||
/** MIME type of the image (e.g. "image/png", "image/gif"). */
|
||||
mime_type: pgText("mime_type").notNull(),
|
||||
/** Epoch millis when this entry was stored. Used for TTL and LRU eviction. */
|
||||
fetched_at: pgBigint("fetched_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
fetchedAtIdx: pgIndex("idx_sticker_cache_fetched_at").on(table.fetched_at),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Corrected Moderations Table (PostgreSQL)
|
||||
* Stores manually corrected false positives from AI moderation.
|
||||
* Used for dynamic few-shot injection in moderation prompts.
|
||||
*/
|
||||
export const pgCorrectedModerationsTable = pgTable(
|
||||
"corrected_moderations",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
/** The message_id that was originally flagged. */
|
||||
message_id: pgText("message_id").notNull(),
|
||||
/** JSON array of original flags assigned by the LLM. */
|
||||
original_flags: pgText("original_flags").notNull(),
|
||||
/** JSON array of corrected flags (may be empty [] for clean). */
|
||||
corrected_flags: pgText("corrected_flags").notNull(),
|
||||
/** Human-readable explanation of why the correction was made. */
|
||||
correction_notes: pgText("correction_notes"),
|
||||
/** Content snippet so the LLM can recognise similar patterns. */
|
||||
content_snippet: pgText("content_snippet").notNull(),
|
||||
/** When this correction was recorded. */
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
createdAtIdx: pgIndex("idx_corrected_moderations_created_at").on(
|
||||
table.created_at,
|
||||
),
|
||||
messageIdIdx: pgIndex("idx_corrected_moderations_message_id").on(
|
||||
table.message_id,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* User Profiles Table (PostgreSQL)
|
||||
* Stores AI-generated summaries of user personality, communication style,
|
||||
* and behavior patterns based on their message history.
|
||||
* Injected as context for AI moderation like channel cultures.
|
||||
*/
|
||||
export const pgUserProfilesTable = pgTable(
|
||||
"user_profiles",
|
||||
{
|
||||
user_id: pgText("user_id").primaryKey(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
profile_summary: pgText("profile_summary").notNull(),
|
||||
last_analyzed_at: pgBigint("last_analyzed_at", {
|
||||
mode: "number",
|
||||
}).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
guildIdx: pgIndex("idx_user_profiles_guild_id").on(table.guild_id),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Mascot Chat Messages Table (PostgreSQL)
|
||||
* Stores AI mascot chat conversation history
|
||||
*/
|
||||
export const pgReactionsTable = pgTable(
|
||||
"message_reactions",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
message_id: pgText("message_id").notNull(),
|
||||
channel_id: pgText("channel_id").notNull(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
user_id: pgText("user_id").notNull(),
|
||||
username: pgText("username").notNull(),
|
||||
emoji: pgText("emoji").notNull(),
|
||||
emoji_id: pgText("emoji_id"),
|
||||
animated: pgBoolean("animated").notNull().default(false),
|
||||
reaction_type: pgText("reaction_type", {
|
||||
enum: ["add", "remove"],
|
||||
}).notNull(),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_reactions_message_id").on(table.message_id),
|
||||
userIdIdx: pgIndex("idx_reactions_user_id").on(table.user_id),
|
||||
guildCreatedIdx: pgIndex("idx_reactions_guild_created").on(
|
||||
table.guild_id,
|
||||
table.created_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const pgMessageEditsTable = pgTable(
|
||||
"message_edits",
|
||||
{
|
||||
id: pgUuid("id").defaultRandom().primaryKey(),
|
||||
message_id: pgText("message_id").notNull(),
|
||||
old_content: pgText("old_content").notNull(),
|
||||
edited_at: pgBigint("edited_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_message_edits_message_id").on(table.message_id),
|
||||
editedAtIdx: pgIndex("idx_message_edits_edited_at").on(table.edited_at),
|
||||
}),
|
||||
);
|
||||
|
||||
export const pgMascotChatMessagesTable = pgTable(
|
||||
"mascot_chat_messages",
|
||||
{
|
||||
id: pgUuid("id").defaultRandom().primaryKey(),
|
||||
user_id: pgText("user_id").notNull(),
|
||||
user_message: pgText("user_message").notNull(),
|
||||
mascot_response: pgText("mascot_response").notNull(),
|
||||
context: pgJsonb("context").notNull().default("{}"),
|
||||
created_at: pgTimestamp("created_at", { withTimezone: true, mode: "date" })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
userCreatedIdx: pgIndex("idx_mascot_chat_messages_user_created").on(
|
||||
table.user_id,
|
||||
table.created_at.desc(),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Runtime table exports
|
||||
// =====================
|
||||
|
||||
export const muxerJobsTable = pgMuxerJobsTable;
|
||||
export const messagesTable = pgMessagesTable;
|
||||
export const attachmentsTable = pgAttachmentsTable;
|
||||
export const uiStateTable = pgUIStateTable;
|
||||
export const aiAnalysisRunsTable = pgAIAnalysisRunsTable;
|
||||
export const voiceRecordingsTable = pgVoiceRecordingsTable;
|
||||
export const messageReviewsTable = pgMessageReviewsTable;
|
||||
export const moderationActionsTable = pgModerationActionsTable;
|
||||
export const retentionPoliciesTable = pgRetentionPoliciesTable;
|
||||
export const textAnalysisCacheTable = pgTextAnalysisCacheTable;
|
||||
export const stickerCacheTable = pgStickerCacheTable;
|
||||
export const correctedModerationsTable = pgCorrectedModerationsTable;
|
||||
export const userReputationsTable = pgUserReputationsTable;
|
||||
export const channelCulturesTable = pgChannelCulturesTable;
|
||||
export const userProfilesTable = pgUserProfilesTable;
|
||||
export const reactionsTable = pgReactionsTable;
|
||||
export const messageEditsTable = pgMessageEditsTable;
|
||||
export const mascotChatMessagesTable = pgMascotChatMessagesTable;
|
||||
|
||||
// Export table types for use in queries
|
||||
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
||||
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
|
||||
|
||||
export type Message = typeof messagesTable.$inferSelect;
|
||||
export type MessageInsert = typeof messagesTable.$inferInsert;
|
||||
|
||||
export type Attachment = typeof attachmentsTable.$inferSelect;
|
||||
export type AttachmentInsert = typeof attachmentsTable.$inferInsert;
|
||||
|
||||
export type UIState = typeof uiStateTable.$inferSelect;
|
||||
export type UIStateInsert = typeof uiStateTable.$inferInsert;
|
||||
|
||||
export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect;
|
||||
export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
|
||||
|
||||
export type VoiceRecording = typeof voiceRecordingsTable.$inferSelect;
|
||||
export type VoiceRecordingInsert = typeof voiceRecordingsTable.$inferInsert;
|
||||
|
||||
export type MessageReview = typeof messageReviewsTable.$inferSelect;
|
||||
export type MessageReviewInsert = typeof messageReviewsTable.$inferInsert;
|
||||
|
||||
export type ModerationAction = typeof moderationActionsTable.$inferSelect;
|
||||
export type ModerationActionInsert = typeof moderationActionsTable.$inferInsert;
|
||||
|
||||
export type RetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
|
||||
export type RetentionPolicyInsert = typeof retentionPoliciesTable.$inferInsert;
|
||||
|
||||
export type StickerCacheRecord = typeof stickerCacheTable.$inferSelect;
|
||||
export type StickerCacheInsert = typeof stickerCacheTable.$inferInsert;
|
||||
|
||||
export type CorrectedModeration = typeof correctedModerationsTable.$inferSelect;
|
||||
export type CorrectedModerationInsert =
|
||||
typeof correctedModerationsTable.$inferInsert;
|
||||
|
||||
export type UserReputation = typeof userReputationsTable.$inferSelect;
|
||||
export type UserReputationInsert = typeof userReputationsTable.$inferInsert;
|
||||
|
||||
export type ChannelCulture = typeof channelCulturesTable.$inferSelect;
|
||||
export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert;
|
||||
|
||||
export type UserProfile = typeof userProfilesTable.$inferSelect;
|
||||
export type UserProfileInsert = typeof userProfilesTable.$inferInsert;
|
||||
|
||||
export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect;
|
||||
export type MascotChatMessageInsert =
|
||||
typeof mascotChatMessagesTable.$inferInsert;
|
||||
export * from "./schema/analytics.js";
|
||||
export * from "./schema/cache.js";
|
||||
export * from "./schema/messages.js";
|
||||
export * from "./schema/meta.js";
|
||||
export * from "./schema/voice.js";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
pgAIAnalysisRunsTable,
|
||||
pgChannelCulturesTable,
|
||||
pgUserProfilesTable,
|
||||
pgUserReputationsTable,
|
||||
} from "@bete/shared";
|
||||
|
||||
// Re-export shared tables
|
||||
export {
|
||||
pgAIAnalysisRunsTable,
|
||||
pgChannelCulturesTable,
|
||||
pgUserProfilesTable,
|
||||
pgUserReputationsTable,
|
||||
};
|
||||
export const aiAnalysisRunsTable = pgAIAnalysisRunsTable;
|
||||
export const channelCulturesTable = pgChannelCulturesTable;
|
||||
export const userProfilesTable = pgUserProfilesTable;
|
||||
export const userReputationsTable = pgUserReputationsTable;
|
||||
|
||||
// Types
|
||||
export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect;
|
||||
export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
|
||||
export type UserReputation = typeof userReputationsTable.$inferSelect;
|
||||
export type UserReputationInsert = typeof userReputationsTable.$inferInsert;
|
||||
export type ChannelCulture = typeof channelCulturesTable.$inferSelect;
|
||||
export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert;
|
||||
export type UserProfile = typeof userProfilesTable.$inferSelect;
|
||||
export type UserProfileInsert = typeof userProfilesTable.$inferInsert;
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
pgCorrectedModerationsTable,
|
||||
pgStickerCacheTable,
|
||||
pgTextAnalysisCacheTable,
|
||||
} from "@bete/shared";
|
||||
|
||||
// Re-export shared tables
|
||||
export {
|
||||
pgCorrectedModerationsTable,
|
||||
pgStickerCacheTable,
|
||||
pgTextAnalysisCacheTable,
|
||||
};
|
||||
export const correctedModerationsTable = pgCorrectedModerationsTable;
|
||||
export const stickerCacheTable = pgStickerCacheTable;
|
||||
export const textAnalysisCacheTable = pgTextAnalysisCacheTable;
|
||||
|
||||
// Types
|
||||
export type StickerCacheRecord = typeof stickerCacheTable.$inferSelect;
|
||||
export type StickerCacheInsert = typeof stickerCacheTable.$inferInsert;
|
||||
export type CorrectedModeration = typeof correctedModerationsTable.$inferSelect;
|
||||
export type CorrectedModerationInsert =
|
||||
typeof correctedModerationsTable.$inferInsert;
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
pgAttachmentsTable,
|
||||
pgMessageReviewsTable,
|
||||
pgMessagesTable,
|
||||
} from "@bete/shared";
|
||||
import {
|
||||
bigint as pgBigint,
|
||||
boolean as pgBoolean,
|
||||
index as pgIndex,
|
||||
pgTable,
|
||||
text as pgText,
|
||||
uuid as pgUuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// Re-export shared message/attachment/review tables
|
||||
export { pgAttachmentsTable, pgMessageReviewsTable, pgMessagesTable };
|
||||
export const messagesTable = pgMessagesTable;
|
||||
export const attachmentsTable = pgAttachmentsTable;
|
||||
export const messageReviewsTable = pgMessageReviewsTable;
|
||||
|
||||
/**
|
||||
* Moderation Actions Table (PostgreSQL)
|
||||
* Tracks actions taken on messages (delete, mute, etc.)
|
||||
*/
|
||||
export const pgModerationActionsTable = pgTable(
|
||||
"moderation_actions",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
message_id: pgText("message_id"),
|
||||
user_id: pgText("user_id"),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
action_type: pgText("action_type", {
|
||||
enum: [
|
||||
"delete_message",
|
||||
"mute_user",
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
],
|
||||
}).notNull(),
|
||||
reason: pgText("reason"),
|
||||
executed_by: pgText("executed_by"),
|
||||
status: pgText("status", {
|
||||
enum: ["pending", "executed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
error: pgText("error"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
executed_at: pgBigint("executed_at", { mode: "number" }),
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
||||
table.message_id,
|
||||
),
|
||||
userIdIdx: pgIndex("idx_moderation_actions_user_id").on(table.user_id),
|
||||
statusIdx: pgIndex("idx_moderation_actions_status").on(table.status),
|
||||
guildStatusIdx: pgIndex("idx_moderation_actions_guild_status").on(
|
||||
table.guild_id,
|
||||
table.status,
|
||||
table.created_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const moderationActionsTable = pgModerationActionsTable;
|
||||
|
||||
/**
|
||||
* Message Edits Table (PostgreSQL)
|
||||
*/
|
||||
export const pgMessageEditsTable = pgTable(
|
||||
"message_edits",
|
||||
{
|
||||
id: pgUuid("id").defaultRandom().primaryKey(),
|
||||
message_id: pgText("message_id").notNull(),
|
||||
old_content: pgText("old_content").notNull(),
|
||||
edited_at: pgBigint("edited_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_message_edits_message_id").on(table.message_id),
|
||||
editedAtIdx: pgIndex("idx_message_edits_edited_at").on(table.edited_at),
|
||||
}),
|
||||
);
|
||||
|
||||
export const messageEditsTable = pgMessageEditsTable;
|
||||
|
||||
/**
|
||||
* Reactions Table (PostgreSQL)
|
||||
*/
|
||||
export const pgReactionsTable = pgTable(
|
||||
"message_reactions",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
message_id: pgText("message_id").notNull(),
|
||||
channel_id: pgText("channel_id").notNull(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
user_id: pgText("user_id").notNull(),
|
||||
username: pgText("username").notNull(),
|
||||
emoji: pgText("emoji").notNull(),
|
||||
emoji_id: pgText("emoji_id"),
|
||||
animated: pgBoolean("animated").notNull().default(false),
|
||||
reaction_type: pgText("reaction_type", {
|
||||
enum: ["add", "remove"],
|
||||
}).notNull(),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_reactions_message_id").on(table.message_id),
|
||||
userIdIdx: pgIndex("idx_reactions_user_id").on(table.user_id),
|
||||
guildCreatedIdx: pgIndex("idx_reactions_guild_created").on(
|
||||
table.guild_id,
|
||||
table.created_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const reactionsTable = pgReactionsTable;
|
||||
|
||||
// Types
|
||||
export type Message = typeof messagesTable.$inferSelect;
|
||||
export type MessageInsert = typeof messagesTable.$inferInsert;
|
||||
export type Attachment = typeof attachmentsTable.$inferSelect;
|
||||
export type AttachmentInsert = typeof attachmentsTable.$inferInsert;
|
||||
export type MessageReview = typeof messageReviewsTable.$inferSelect;
|
||||
export type MessageReviewInsert = typeof messageReviewsTable.$inferInsert;
|
||||
export type ModerationAction = typeof moderationActionsTable.$inferSelect;
|
||||
export type ModerationActionInsert = typeof moderationActionsTable.$inferInsert;
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
pgMascotChatMessagesTable,
|
||||
pgMuxerJobsTable,
|
||||
pgRetentionPoliciesTable,
|
||||
pgUIStateTable,
|
||||
} from "@bete/shared";
|
||||
|
||||
// Re-export shared tables
|
||||
export {
|
||||
pgMascotChatMessagesTable,
|
||||
pgMuxerJobsTable,
|
||||
pgRetentionPoliciesTable,
|
||||
pgUIStateTable,
|
||||
};
|
||||
export const muxerJobsTable = pgMuxerJobsTable;
|
||||
export const uiStateTable = pgUIStateTable;
|
||||
export const retentionPoliciesTable = pgRetentionPoliciesTable;
|
||||
export const mascotChatMessagesTable = pgMascotChatMessagesTable;
|
||||
|
||||
// Types
|
||||
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
||||
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
|
||||
export type UIState = typeof uiStateTable.$inferSelect;
|
||||
export type UIStateInsert = typeof uiStateTable.$inferInsert;
|
||||
export type RetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
|
||||
export type RetentionPolicyInsert = typeof retentionPoliciesTable.$inferInsert;
|
||||
export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect;
|
||||
export type MascotChatMessageInsert =
|
||||
typeof mascotChatMessagesTable.$inferInsert;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { pgVoiceRecordingsTable } from "@bete/shared";
|
||||
|
||||
// Re-export shared table
|
||||
export { pgVoiceRecordingsTable };
|
||||
export const voiceRecordingsTable = pgVoiceRecordingsTable;
|
||||
|
||||
// Types
|
||||
export type VoiceRecording = typeof voiceRecordingsTable.$inferSelect;
|
||||
export type VoiceRecordingInsert = typeof voiceRecordingsTable.$inferInsert;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { retryWithBackoff } from "@bete/shared/utils";
|
||||
|
||||
const logger = createChildLogger("tele-upload");
|
||||
|
||||
export interface TeleUploadResponse {
|
||||
download_url: string;
|
||||
public_id?: string;
|
||||
file_name?: string;
|
||||
size_bytes?: number;
|
||||
}
|
||||
|
||||
export interface TeleUploadResult {
|
||||
url: string;
|
||||
publicId?: string;
|
||||
filename?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export function parseTeleUploadResponse(
|
||||
response: TeleUploadResponse,
|
||||
): TeleUploadResult {
|
||||
if (!response.download_url) {
|
||||
throw new Error("Missing download_url in response");
|
||||
}
|
||||
|
||||
return {
|
||||
url: response.download_url,
|
||||
publicId: response.public_id,
|
||||
filename: response.file_name,
|
||||
sizeBytes: response.size_bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadToTele(input: {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
uploadUrl: string;
|
||||
timeoutMs?: number;
|
||||
retries: number;
|
||||
}): Promise<TeleUploadResult> {
|
||||
const { buffer, filename, contentType, uploadUrl, timeoutMs, retries } =
|
||||
input;
|
||||
|
||||
logger.debug({ filename, uploadUrl }, "Starting tele upload");
|
||||
|
||||
const response = await retryWithBackoff(
|
||||
async () => {
|
||||
const fileBlob = new Blob([new Uint8Array(buffer)], {
|
||||
type: contentType,
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileBlob, filename);
|
||||
formData.append("fileName", filename);
|
||||
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
body: formData,
|
||||
...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload failed: Status ${res.status}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as TeleUploadResponse;
|
||||
},
|
||||
{
|
||||
retries,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
return parseTeleUploadResponse(response);
|
||||
}
|
||||
Reference in New Issue
Block a user