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,5 +1,7 @@
|
||||
import { pgMessagesTable } from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { and, desc, eq, ilike, type SQL } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import {
|
||||
type MappedMessage,
|
||||
mapMessageRow,
|
||||
@@ -19,44 +21,27 @@ export type AnalysisSearchResult = MappedMessage;
|
||||
|
||||
export class AnalysisRepository {
|
||||
async search(query: AnalysisSearchQuery): Promise<AnalysisSearchResult[]> {
|
||||
const pool = getPool();
|
||||
const db = getDatabase();
|
||||
const { q = "", channelId, guildId, limit = 20 } = query;
|
||||
|
||||
logger.debug({ q, channelId, guildId, limit }, "Searching analysis");
|
||||
|
||||
const searchPattern = `%${q}%`;
|
||||
const clauses: string[] = ["content ILIKE $1"];
|
||||
const params: (string | number)[] = [searchPattern];
|
||||
let p = 2;
|
||||
const conditions: SQL[] = [ilike(pgMessagesTable.content, `%${q}%`)];
|
||||
|
||||
if (guildId) {
|
||||
clauses.push(`guild_id = $${p}`);
|
||||
params.push(guildId);
|
||||
p++;
|
||||
conditions.push(eq(pgMessagesTable.guild_id, guildId));
|
||||
}
|
||||
|
||||
if (channelId) {
|
||||
clauses.push(`channel_id = $${p}`);
|
||||
params.push(channelId);
|
||||
p++;
|
||||
conditions.push(eq(pgMessagesTable.channel_id, channelId));
|
||||
}
|
||||
|
||||
const where = clauses.join(" AND ");
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
id, guild_id, channel_id, thread_id,
|
||||
user_id, username, avatar_url,
|
||||
content, edited_content, created_at, edited_at, deleted_at,
|
||||
type, metadata,
|
||||
ai_status, ai_moderation_flags, ai_moderation_score,
|
||||
ai_analysis, ai_categories, ai_severity, ai_confidence,
|
||||
ai_recommended_action, ai_analyzed_at, ai_error
|
||||
FROM messages
|
||||
WHERE ${where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${p}`,
|
||||
[...params, limit],
|
||||
);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pgMessagesTable)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(pgMessagesTable.created_at))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((r) => mapMessageRow(r as Record<string, unknown>));
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { createAnalysisRouter } from "./analysis.routes.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { createConfigRouter } from "./config.routes.js";
|
||||
@@ -1,16 +1,23 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import {
|
||||
pgChannelCulturesTable,
|
||||
pgMessagesTable,
|
||||
pgUserProfilesTable,
|
||||
pgUserReputationsTable,
|
||||
pgVoiceRecordingsTable,
|
||||
} from "@bete/shared";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import type { ListUsersQuery } from "./dashboard.service.js";
|
||||
|
||||
const _logger = createChildLogger("dashboard.repository");
|
||||
|
||||
export class DashboardRepository {
|
||||
async getStats() {
|
||||
const pool = getPool();
|
||||
const db = getDatabase();
|
||||
|
||||
const oneDayAgo = Date.now() - 86400000;
|
||||
|
||||
// Total messages and breakdown by ai_status
|
||||
const msgResult = await pool.query(
|
||||
`
|
||||
const msgResult = await db.execute(sql`
|
||||
SELECT
|
||||
COUNT(*)::int AS total_messages,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS total_flagged,
|
||||
@@ -20,32 +27,30 @@ export class DashboardRepository {
|
||||
COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS total_pending,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'processing')::int AS total_processing,
|
||||
COUNT(DISTINCT user_id)::int AS total_users,
|
||||
COUNT(*) FILTER (WHERE created_at >= $1)::int AS today_messages,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= $1)::int AS today_flagged,
|
||||
COUNT(DISTINCT user_id) FILTER (WHERE created_at >= $2)::int AS active_users_24h
|
||||
FROM messages
|
||||
`,
|
||||
[Date.now() - 86400000, Date.now() - 86400000],
|
||||
);
|
||||
COUNT(*) FILTER (WHERE created_at >= ${oneDayAgo})::int AS today_messages,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= ${oneDayAgo})::int AS today_flagged,
|
||||
COUNT(DISTINCT user_id) FILTER (WHERE created_at >= ${oneDayAgo})::int AS active_users_24h
|
||||
FROM ${pgMessagesTable}
|
||||
`);
|
||||
|
||||
const msgRow = msgResult.rows[0];
|
||||
const msgRow = msgResult.rows[0] as Record<string, unknown> | undefined;
|
||||
|
||||
// Total voice recordings
|
||||
const voiceResult = await pool.query(`
|
||||
SELECT COUNT(*)::int AS count FROM voice_recordings
|
||||
const voiceResult = await db.execute(sql`
|
||||
SELECT COUNT(*)::int AS count FROM ${pgVoiceRecordingsTable}
|
||||
`);
|
||||
|
||||
// Total AI user profiles
|
||||
const profileResult = await pool.query(`
|
||||
SELECT COUNT(*)::int AS count FROM user_profiles
|
||||
const profileResult = await db.execute(sql`
|
||||
SELECT COUNT(*)::int AS count FROM ${pgUserProfilesTable}
|
||||
`);
|
||||
|
||||
// Top channels by message count
|
||||
const topChannels = await pool.query(`
|
||||
const topChannels = await db.execute(sql`
|
||||
SELECT channel_id,
|
||||
COALESCE(NULLIF((metadata::jsonb -> 'channel' ->> 'channelName'), ''), channel_id) AS channel_name,
|
||||
COUNT(*)::int AS message_count
|
||||
FROM messages
|
||||
FROM ${pgMessagesTable}
|
||||
WHERE metadata IS NOT NULL AND metadata != ''
|
||||
GROUP BY channel_id, (metadata::jsonb -> 'channel' ->> 'channelName')
|
||||
ORDER BY COUNT(*) DESC
|
||||
@@ -78,31 +83,26 @@ export class DashboardRepository {
|
||||
}
|
||||
|
||||
async listUsers(query: ListUsersQuery) {
|
||||
const pool = getPool();
|
||||
const db = getDatabase();
|
||||
const limit = query.limit ?? 20;
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (query.search) {
|
||||
conditions.push(
|
||||
`(m.user_id ILIKE $${paramIdx} OR m.username ILIKE $${paramIdx})`,
|
||||
sql`(m.user_id ILIKE ${`%${query.search}%`} OR m.username ILIKE ${`%${query.search}%`})`,
|
||||
);
|
||||
params.push(`%${query.search}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (query.cursor) {
|
||||
conditions.push(`m.last_message_at < $${paramIdx}`);
|
||||
params.push(Number(query.cursor));
|
||||
paramIdx++;
|
||||
conditions.push(sql`m.last_message_at < ${Number(query.cursor)}`);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
conditions.length > 0
|
||||
? sql`WHERE ${sql.join(conditions, sql` AND `)}`
|
||||
: sql``;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
const { rows } = await db.execute(sql`
|
||||
SELECT
|
||||
m.user_id,
|
||||
m.username,
|
||||
@@ -120,17 +120,15 @@ export class DashboardRepository {
|
||||
COUNT(*)::int AS total_messages,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
|
||||
MAX(created_at) AS last_message_at
|
||||
FROM messages
|
||||
FROM ${pgMessagesTable}
|
||||
GROUP BY user_id, username, avatar_url
|
||||
) m
|
||||
LEFT JOIN user_profiles p ON p.user_id = m.user_id
|
||||
LEFT JOIN user_reputations r ON r.user_id = m.user_id
|
||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
||||
${whereClause}
|
||||
ORDER BY m.last_message_at DESC NULLS LAST
|
||||
LIMIT $${paramIdx}
|
||||
`,
|
||||
[...params, limit + 1],
|
||||
);
|
||||
LIMIT ${limit + 1}
|
||||
`);
|
||||
|
||||
const data = (rows as Record<string, unknown>[])
|
||||
.slice(0, limit)
|
||||
@@ -158,31 +156,26 @@ export class DashboardRepository {
|
||||
}
|
||||
|
||||
async listChannels(query: ListUsersQuery & { guildId?: string }) {
|
||||
const pool = getPool();
|
||||
const db = getDatabase();
|
||||
const limit = query.limit ?? 20;
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (query.search) {
|
||||
conditions.push(
|
||||
`(m.channel_id ILIKE $${paramIdx} OR m.channel_name ILIKE $${paramIdx})`,
|
||||
sql`(m.channel_id ILIKE ${`%${query.search}%`} OR m.channel_name ILIKE ${`%${query.search}%`})`,
|
||||
);
|
||||
params.push(`%${query.search}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (query.guildId) {
|
||||
conditions.push(`m.guild_id = $${paramIdx}`);
|
||||
params.push(query.guildId);
|
||||
paramIdx++;
|
||||
conditions.push(sql`m.guild_id = ${query.guildId}`);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
conditions.length > 0
|
||||
? sql`WHERE ${sql.join(conditions, sql` AND `)}`
|
||||
: sql``;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
const { rows } = await db.execute(sql`
|
||||
SELECT
|
||||
m.channel_id,
|
||||
m.channel_name,
|
||||
@@ -200,16 +193,14 @@ export class DashboardRepository {
|
||||
COUNT(*)::int AS total_messages,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
|
||||
MAX(created_at) AS last_message_at
|
||||
FROM messages
|
||||
FROM ${pgMessagesTable}
|
||||
GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName')
|
||||
) m
|
||||
LEFT JOIN channel_cultures c ON c.channel_id = m.channel_id
|
||||
LEFT JOIN ${pgChannelCulturesTable} c ON c.channel_id = m.channel_id
|
||||
${whereClause}
|
||||
ORDER BY m.total_messages DESC
|
||||
LIMIT $${paramIdx}
|
||||
`,
|
||||
[...params, limit + 1],
|
||||
);
|
||||
LIMIT ${limit + 1}
|
||||
`);
|
||||
|
||||
const data = ((rows as Record<string, unknown>[]) || [])
|
||||
.slice(0, limit)
|
||||
@@ -234,10 +225,9 @@ export class DashboardRepository {
|
||||
}
|
||||
|
||||
async getChannelDetail(channelId: string) {
|
||||
const pool = getPool();
|
||||
const db = getDatabase();
|
||||
|
||||
const channelResult = await pool.query(
|
||||
`
|
||||
const channelResult = await db.execute(sql`
|
||||
SELECT
|
||||
m.channel_id,
|
||||
m.channel_name,
|
||||
@@ -255,28 +245,23 @@ export class DashboardRepository {
|
||||
COUNT(*)::int AS total_messages,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count
|
||||
FROM messages
|
||||
WHERE channel_id = $1
|
||||
FROM ${pgMessagesTable}
|
||||
WHERE channel_id = ${channelId}
|
||||
GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName')
|
||||
) m
|
||||
LEFT JOIN channel_cultures c ON c.channel_id = m.channel_id
|
||||
`,
|
||||
[channelId],
|
||||
);
|
||||
LEFT JOIN ${pgChannelCulturesTable} c ON c.channel_id = m.channel_id
|
||||
`);
|
||||
|
||||
const row = channelResult.rows[0] as Record<string, unknown> | undefined;
|
||||
if (!row) return null;
|
||||
|
||||
const recent = await pool.query(
|
||||
`
|
||||
const recent = await db.execute(sql`
|
||||
SELECT id, content, channel_id, created_at, ai_status, username
|
||||
FROM messages
|
||||
WHERE channel_id = $1
|
||||
FROM ${pgMessagesTable}
|
||||
WHERE channel_id = ${channelId}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
`,
|
||||
[channelId],
|
||||
);
|
||||
`);
|
||||
|
||||
return {
|
||||
channel_id: String(row.channel_id),
|
||||
@@ -301,11 +286,9 @@ export class DashboardRepository {
|
||||
}
|
||||
|
||||
async getUserDetail(userId: string) {
|
||||
const pool = getPool();
|
||||
const db = getDatabase();
|
||||
|
||||
// Basic user info + profile + reputation
|
||||
const userResult = await pool.query(
|
||||
`
|
||||
const userResult = await db.execute(sql`
|
||||
SELECT
|
||||
m.user_id,
|
||||
m.username,
|
||||
@@ -326,32 +309,26 @@ export class DashboardRepository {
|
||||
COUNT(*)::int AS total_messages,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count
|
||||
FROM messages
|
||||
WHERE user_id = $1
|
||||
FROM ${pgMessagesTable}
|
||||
WHERE user_id = ${userId}
|
||||
GROUP BY user_id, username, avatar_url
|
||||
) m
|
||||
LEFT JOIN user_profiles p ON p.user_id = m.user_id
|
||||
LEFT JOIN user_reputations r ON r.user_id = m.user_id
|
||||
`,
|
||||
[userId],
|
||||
);
|
||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
||||
`);
|
||||
|
||||
const row = userResult.rows[0] as Record<string, unknown> | undefined;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Recent messages
|
||||
const recent = await pool.query(
|
||||
`
|
||||
const recent = await db.execute(sql`
|
||||
SELECT id, content, channel_id, created_at, ai_status
|
||||
FROM messages
|
||||
WHERE user_id = $1
|
||||
FROM ${pgMessagesTable}
|
||||
WHERE user_id = ${userId}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
`,
|
||||
[userId],
|
||||
);
|
||||
`);
|
||||
|
||||
return {
|
||||
user_id: String(row.user_id),
|
||||
@@ -364,13 +341,13 @@ export class DashboardRepository {
|
||||
last_analyzed_at: row.last_analyzed_at
|
||||
? Number(row.last_analyzed_at)
|
||||
: null,
|
||||
trust_score: row.trust_score !== null ? Number(row.trust_score) : null,
|
||||
trust_score: row.trust_score != null ? Number(row.trust_score) : null,
|
||||
clean_message_streak:
|
||||
row.clean_message_streak !== null
|
||||
row.clean_message_streak != null
|
||||
? Number(row.clean_message_streak)
|
||||
: null,
|
||||
total_infractions:
|
||||
row.total_infractions !== null ? Number(row.total_infractions) : null,
|
||||
row.total_infractions != null ? Number(row.total_infractions) : null,
|
||||
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
|
||||
id: String(r.id),
|
||||
content: String(r.content),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { createDashboardRouter } from "./dashboard.routes.js";
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
|
||||
const logger = createChildLogger("health.repository");
|
||||
|
||||
@@ -7,8 +8,8 @@ export class HealthRepository {
|
||||
async checkDatabaseConnection() {
|
||||
try {
|
||||
logger.debug("Running database health check");
|
||||
const pool = getPool();
|
||||
await pool.query("SELECT 1 AS result");
|
||||
const db = getDatabase();
|
||||
await db.execute(sql`SELECT 1 AS result`);
|
||||
logger.debug("Database health check passed");
|
||||
return { connected: true };
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { healthRepository } from "./health.repository.js";
|
||||
|
||||
const _logger = createChildLogger("health.service");
|
||||
|
||||
export class HealthService {
|
||||
async getHealth(verbose = false) {
|
||||
const dbStatus = await healthRepository.checkDatabaseConnection();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { createHealthRouter } from "./health.routes.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { createMascotChatRouter } from "./mascot-chat.routes.js";
|
||||
@@ -11,8 +11,12 @@ interface AuthenticatedRequest extends Request {
|
||||
|
||||
export const handleMascotChat = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const { message, context } = req.body;
|
||||
const { message, context } = req.body as {
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!message || typeof message !== "string") {
|
||||
return res.status(400).json({
|
||||
error: "INVALID_INPUT",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { pgMascotChatMessagesTable, pgMessagesTable } from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
|
||||
const logger = createChildLogger("mascot-chat.repository");
|
||||
|
||||
@@ -38,22 +40,15 @@ export interface ServerInsights {
|
||||
|
||||
export class MascotChatRepository {
|
||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||
const pool = getPool();
|
||||
const db = getDatabase();
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO mascot_chat_messages
|
||||
(user_id, user_message, mascot_response, context, created_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5)
|
||||
`,
|
||||
[
|
||||
input.userId,
|
||||
input.userMessage,
|
||||
input.mascotResponse,
|
||||
JSON.stringify(input.context ?? {}),
|
||||
input.timestamp.toISOString(),
|
||||
],
|
||||
);
|
||||
await db.insert(pgMascotChatMessagesTable).values({
|
||||
user_id: input.userId,
|
||||
user_message: input.userMessage,
|
||||
mascot_response: input.mascotResponse,
|
||||
context: (input.context ?? {}) as Record<string, unknown>,
|
||||
created_at: input.timestamp,
|
||||
});
|
||||
|
||||
logger.debug({ userId: input.userId }, "Conversation saved");
|
||||
}
|
||||
@@ -62,69 +57,61 @@ export class MascotChatRepository {
|
||||
userId: string,
|
||||
limit: number,
|
||||
): Promise<MascotChatHistoryRow[]> {
|
||||
const pool = getPool();
|
||||
const db = getDatabase();
|
||||
|
||||
const { rows } = await pool.query<MascotChatHistoryRow>(
|
||||
`
|
||||
SELECT id, user_id, user_message, mascot_response, context, created_at
|
||||
FROM mascot_chat_messages
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`,
|
||||
[userId, limit],
|
||||
);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pgMascotChatMessagesTable)
|
||||
.where(eq(pgMascotChatMessagesTable.user_id, userId))
|
||||
.orderBy(desc(pgMascotChatMessagesTable.created_at))
|
||||
.limit(limit);
|
||||
|
||||
logger.debug({ userId, count: rows.length }, "Chat history fetched");
|
||||
return rows.reverse();
|
||||
return rows.reverse() as unknown as MascotChatHistoryRow[];
|
||||
}
|
||||
|
||||
async clearChatHistory(userId: string): Promise<void> {
|
||||
const pool = getPool();
|
||||
const db = getDatabase();
|
||||
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM mascot_chat_messages WHERE user_id = $1`,
|
||||
[userId],
|
||||
const deleted = await db
|
||||
.delete(pgMascotChatMessagesTable)
|
||||
.where(eq(pgMascotChatMessagesTable.user_id, userId))
|
||||
.returning({ id: pgMascotChatMessagesTable.id });
|
||||
|
||||
logger.info(
|
||||
{ userId, deletedRows: deleted.length },
|
||||
"Chat history cleared",
|
||||
);
|
||||
|
||||
logger.info({ userId, deletedRows: rowCount ?? 0 }, "Chat history cleared");
|
||||
}
|
||||
|
||||
async getServerInsights(
|
||||
guildId?: string,
|
||||
channelId?: string,
|
||||
): Promise<ServerInsights> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const params: string[] = [];
|
||||
const clauses: string[] = [];
|
||||
const db = getDatabase();
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (guildId) {
|
||||
params.push(guildId);
|
||||
clauses.push(`guild_id = $${params.length}`);
|
||||
conditions.push(eq(pgMessagesTable.guild_id, guildId));
|
||||
}
|
||||
if (channelId) {
|
||||
params.push(channelId);
|
||||
clauses.push(`channel_id = $${params.length}`);
|
||||
conditions.push(eq(pgMessagesTable.channel_id, channelId));
|
||||
}
|
||||
|
||||
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const { rows } = await pool.query<ServerInsights>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS total_messages,
|
||||
COUNT(DISTINCT user_id)::int AS active_users,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned
|
||||
FROM messages
|
||||
${where}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
const [result] = await db
|
||||
.select({
|
||||
total_messages: sql<number>`COUNT(*)::int`,
|
||||
active_users: sql<number>`COUNT(DISTINCT ${pgMessagesTable.user_id})::int`,
|
||||
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
||||
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
||||
})
|
||||
.from(pgMessagesTable)
|
||||
.where(where);
|
||||
|
||||
const insights = rows[0] ?? {
|
||||
const insights = result ?? {
|
||||
total_messages: 0,
|
||||
active_users: 0,
|
||||
flagged: 0,
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import express, { type Router } from "express";
|
||||
import { validateBody } from "../../shared/middlewares/index.js";
|
||||
import {
|
||||
clearMascotChatHistory,
|
||||
getMascotChatHistory,
|
||||
handleMascotChat,
|
||||
} from "./mascot-chat.controller.js";
|
||||
import { chatRequestSchema } from "./mascot-chat.schema.js";
|
||||
|
||||
export function createMascotChatRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/mascot/chat", handleMascotChat);
|
||||
router.post(
|
||||
"/mascot/chat",
|
||||
validateBody(chatRequestSchema),
|
||||
handleMascotChat,
|
||||
);
|
||||
router.get("/mascot/chat/history", getMascotChatHistory);
|
||||
router.delete("/mascot/chat/history", clearMascotChatHistory);
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { createMediaRouter } from "./media.routes.js";
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
||||
import { mediaQueueSchema, mediaVolumeSchema } from "./media.schema.js";
|
||||
import { getStatus, queue, setVolume, skip, stop } from "./media.service.js";
|
||||
|
||||
const logger = createChildLogger("media.routes");
|
||||
@@ -22,16 +23,12 @@ export function createMediaRouter(): Router {
|
||||
// POST /api/media/queue
|
||||
router.post(
|
||||
"/media/queue",
|
||||
validateBody(mediaQueueSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const source = req.body?.source as string | undefined;
|
||||
if (!source) {
|
||||
res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "source is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const mode = (req.body?.mode as "music" | "screen") ?? "music";
|
||||
const { source, mode } = req.body as {
|
||||
source: string;
|
||||
mode: "music" | "screen";
|
||||
};
|
||||
logger.debug({ source, mode }, "Media queue requested");
|
||||
const state = await queue(source, mode);
|
||||
res.json(state);
|
||||
@@ -61,15 +58,9 @@ export function createMediaRouter(): Router {
|
||||
// POST /api/media/volume
|
||||
router.post(
|
||||
"/media/volume",
|
||||
validateBody(mediaVolumeSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const volume = Number(req.body?.volume ?? 1.0);
|
||||
if (Number.isNaN(volume) || volume < 0 || volume > 1) {
|
||||
res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "volume must be a number between 0 and 1",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { volume } = req.body as { volume: number };
|
||||
logger.debug({ volume }, "Media volume requested");
|
||||
const state = await setVolume(volume);
|
||||
res.json(state);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const mediaQueueSchema = z.object({
|
||||
source: z.string().min(1, "source is required"),
|
||||
mode: z.enum(["music", "screen"]).default("music"),
|
||||
});
|
||||
|
||||
export const mediaVolumeSchema = z.object({
|
||||
volume: z.number().min(0).max(1).default(1.0),
|
||||
});
|
||||
|
||||
export type MediaQueueInput = z.infer<typeof mediaQueueSchema>;
|
||||
export type MediaVolumeInput = z.infer<typeof mediaVolumeSchema>;
|
||||
@@ -0,0 +1 @@
|
||||
export { createMessagesRouter } from "./messages.routes.js";
|
||||
@@ -1,84 +1,68 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { asyncHandler, requireParam } from "../../shared/middlewares/index.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { messageQuerySchema } from "./messages.schema.js";
|
||||
import { messagesService } from "./messages.service.js";
|
||||
|
||||
const logger = createChildLogger("messages.controller");
|
||||
|
||||
export function handleListMessages(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
export const handleListMessages = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
logger.debug({ query }, "Handling list messages request");
|
||||
const result = await messagesService.listMessages(query);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function handleGetMessagesByChannel(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const channelId = requireParam(
|
||||
req.params.channelId,
|
||||
"route parameter",
|
||||
"channelId",
|
||||
);
|
||||
export const handleGetMessagesByChannel = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
if (!req.params.channelId) {
|
||||
res.status(400).json({ error: "Missing route parameter: channelId" });
|
||||
return;
|
||||
}
|
||||
const channelId = req.params.channelId as string;
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
logger.debug({ channelId, query }, "Handling get messages by channel");
|
||||
const result = await messagesService.getMessagesByChannel(channelId, query);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function handleGetMessageById(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const id = requireParam(req.params.id, "route parameter", "id");
|
||||
export const handleGetMessageById = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
if (!req.params.id) {
|
||||
res.status(400).json({ error: "Missing route parameter: id" });
|
||||
return;
|
||||
}
|
||||
const id = req.params.id as string;
|
||||
logger.debug({ id }, "Handling get message by ID");
|
||||
const result = await messagesService.getMessageById(id);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function handleGetImageMessages(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = requireParam(
|
||||
req.query.guildId as string,
|
||||
"query parameter",
|
||||
"guildId",
|
||||
);
|
||||
export const handleGetImageMessages = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const guildId = req.query.guildId as string | undefined;
|
||||
if (!guildId) {
|
||||
res.status(400).json({ error: "Missing query parameter: guildId" });
|
||||
return;
|
||||
}
|
||||
const limit = Number(req.query.limit) || 50;
|
||||
logger.debug({ guildId, limit }, "Handling get image messages");
|
||||
const result = await messagesService.getImageMessages(guildId, limit);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function handleGetAttachmentsByChannel(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
return asyncHandler(async (req: Request, res: Response) => {
|
||||
const channelId = requireParam(
|
||||
req.params.channelId,
|
||||
"route parameter",
|
||||
"channelId",
|
||||
);
|
||||
export const handleGetAttachmentsByChannel = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
if (!req.params.channelId) {
|
||||
res.status(400).json({ error: "Missing route parameter: channelId" });
|
||||
return;
|
||||
}
|
||||
const channelId = req.params.channelId as string;
|
||||
const query = messageQuerySchema.parse(req.query);
|
||||
logger.debug({ channelId, query }, "Handling get attachments by channel");
|
||||
const result = await messagesService.getAttachmentsByChannel(
|
||||
@@ -86,5 +70,5 @@ export function handleGetAttachmentsByChannel(
|
||||
query,
|
||||
);
|
||||
res.json(result);
|
||||
})(req, res, next);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
or,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { mapMessageRow } from "../../shared/utils/messageMapper.js";
|
||||
import type {
|
||||
@@ -23,12 +24,12 @@ import type {
|
||||
} from "./messages.schema.js";
|
||||
|
||||
/**
|
||||
* Thread IDs to exclude from all message queries.
|
||||
* Thread/channel IDs to exclude from all message queries.
|
||||
* Messages in these threads (e.g. bot/selfbot spam) are skipped
|
||||
* both at capture time (discord-gateway) and when serving data
|
||||
* (backend API).
|
||||
* (backend API). Configured via EXCLUDED_THREAD_IDS and EXCLUDED_CHANNEL_IDS.
|
||||
*/
|
||||
const EXCLUDED_THREAD_IDS = ["1522077685508083893"];
|
||||
const EXCLUDED_THREAD_IDS = config.EXCLUDED_THREAD_IDS;
|
||||
|
||||
const logger = createChildLogger("messages.repository");
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
||||
import {
|
||||
handleGetAttachmentsByChannel,
|
||||
handleGetImageMessages,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
handleGetMessagesByChannel,
|
||||
handleListMessages,
|
||||
} from "./messages.controller.js";
|
||||
import { reanalyzeBatchSchema } from "./messages.schema.js";
|
||||
import { messagesService } from "./messages.service.js";
|
||||
|
||||
const logger = createChildLogger("messages.routes");
|
||||
@@ -55,8 +56,9 @@ export function createMessagesRouter(): Router {
|
||||
// is not captured as an :id param.
|
||||
router.post(
|
||||
"/messages/reanalyze-batch",
|
||||
validateBody(reanalyzeBatchSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { guildId, channelId, messageIds } = (req.body ?? {}) as {
|
||||
const { guildId, channelId, messageIds } = req.body as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
messageIds?: string[];
|
||||
|
||||
@@ -36,6 +36,13 @@ export const messageUpdateSchema = z.object({
|
||||
aiConfidence: z.number().optional(),
|
||||
});
|
||||
|
||||
export const reanalyzeBatchSchema = z.object({
|
||||
guildId: z.string().optional(),
|
||||
channelId: z.string().optional(),
|
||||
messageIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
||||
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
||||
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
||||
export type ReanalyzeBatchInput = z.infer<typeof reanalyzeBatchSchema>;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { createRecordingsRouter } from "./recordings.routes.js";
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pgVoiceRecordingsTable } from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { and, desc, eq, lt, type SQL } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
|
||||
const logger = createChildLogger("recordings.service");
|
||||
@@ -36,37 +37,47 @@ export class RecordingsService {
|
||||
logger.info({ limit }, "getRecent called");
|
||||
const db = getDatabase();
|
||||
|
||||
const conditions: ReturnType<typeof sql>[] = [];
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (filters?.cursor) {
|
||||
conditions.push(sql`created_at < ${filters.cursor}::numeric`);
|
||||
conditions.push(
|
||||
lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)),
|
||||
);
|
||||
}
|
||||
if (filters?.channelId) {
|
||||
conditions.push(sql`channel_id = ${filters.channelId}`);
|
||||
conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId));
|
||||
}
|
||||
if (filters?.userId) {
|
||||
conditions.push(sql`user_id = ${filters.userId}`);
|
||||
conditions.push(eq(pgVoiceRecordingsTable.user_id, filters.userId));
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0
|
||||
? sql`WHERE ${sql.join(conditions, sql` AND `)}`
|
||||
: sql``;
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const { rows } = await db.execute(sql`
|
||||
SELECT
|
||||
id, user_id, username, avatar_url, guild_id, channel_id,
|
||||
channel_name, filename, size_bytes, download_url,
|
||||
upload_status, upload_error, created_at, uploaded_at,
|
||||
COALESCE(size_bytes, 0) AS duration_bytes
|
||||
FROM voice_recordings
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit + 1}
|
||||
`);
|
||||
const allRows = await db
|
||||
.select({
|
||||
id: pgVoiceRecordingsTable.id,
|
||||
user_id: pgVoiceRecordingsTable.user_id,
|
||||
username: pgVoiceRecordingsTable.username,
|
||||
avatar_url: pgVoiceRecordingsTable.avatar_url,
|
||||
guild_id: pgVoiceRecordingsTable.guild_id,
|
||||
channel_id: pgVoiceRecordingsTable.channel_id,
|
||||
channel_name: pgVoiceRecordingsTable.channel_name,
|
||||
filename: pgVoiceRecordingsTable.filename,
|
||||
size_bytes: pgVoiceRecordingsTable.size_bytes,
|
||||
download_url: pgVoiceRecordingsTable.download_url,
|
||||
upload_status: pgVoiceRecordingsTable.upload_status,
|
||||
upload_error: pgVoiceRecordingsTable.upload_error,
|
||||
created_at: pgVoiceRecordingsTable.created_at,
|
||||
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
|
||||
duration_bytes: pgVoiceRecordingsTable.size_bytes,
|
||||
})
|
||||
.from(pgVoiceRecordingsTable)
|
||||
.where(where)
|
||||
.orderBy(desc(pgVoiceRecordingsTable.created_at))
|
||||
.limit(limit + 1);
|
||||
|
||||
const items = rows.slice(0, limit) as unknown as RecordingRow[];
|
||||
const hasMore = rows.length > limit;
|
||||
const items = allRows.slice(0, limit) as unknown as RecordingRow[];
|
||||
const hasMore = allRows.length > limit;
|
||||
const nextCursor = hasMore
|
||||
? String(items[items.length - 1]?.created_at)
|
||||
: null;
|
||||
@@ -76,7 +87,9 @@ export class RecordingsService {
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
const db = getDatabase();
|
||||
await db.execute(sql`DELETE FROM voice_recordings WHERE id = ${id}`);
|
||||
await db
|
||||
.delete(pgVoiceRecordingsTable)
|
||||
.where(eq(pgVoiceRecordingsTable.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { createUiStateRouter } from "./ui-state.routes.js";
|
||||
@@ -1,49 +0,0 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import {
|
||||
getGuilds,
|
||||
getTextChannels,
|
||||
getVoiceChannels,
|
||||
} from "./voice.service.js";
|
||||
|
||||
const logger = createChildLogger("guilds.routes");
|
||||
|
||||
export function createGuildsRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/guilds
|
||||
router.get(
|
||||
"/",
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
logger.debug("Fetching guilds");
|
||||
const guilds = await getGuilds();
|
||||
res.json(guilds);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /api/guilds/:guildId/channels
|
||||
router.get(
|
||||
"/:guildId/channels",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = req.params.guildId as string;
|
||||
logger.debug({ guildId }, "Fetching text channels");
|
||||
const channels = await getTextChannels(guildId);
|
||||
res.json(channels);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /api/guilds/:guildId/voice-channels
|
||||
router.get(
|
||||
"/:guildId/voice-channels",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = req.params.guildId as string;
|
||||
logger.debug({ guildId }, "Fetching voice channels");
|
||||
const channels = await getVoiceChannels(guildId);
|
||||
res.json(channels);
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createVoiceRouter } from "./voice.routes.js";
|
||||
@@ -2,6 +2,7 @@ import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response } from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { publishCommandNoReply } from "../../shared/redis/index.js";
|
||||
import type { ConnectVoiceInput, VoiceCommandInput } from "./voice.schema.js";
|
||||
import {
|
||||
connectVoice,
|
||||
disconnectVoice,
|
||||
@@ -17,22 +18,9 @@ export const handleGetVoiceStatus = asyncHandler(
|
||||
},
|
||||
);
|
||||
|
||||
/** Safely extract a string value that may be a single string or string array. */
|
||||
function asString(val: unknown): string {
|
||||
if (Array.isArray(val)) return String(val[0] ?? "");
|
||||
return String(val ?? "");
|
||||
}
|
||||
|
||||
export const handleConnectVoice = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const guildId = asString(req.body.guildId);
|
||||
const channelId = asString(req.body.channelId);
|
||||
if (!guildId || !channelId) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "guildId and channelId are required",
|
||||
});
|
||||
}
|
||||
const { guildId, channelId } = req.body as ConnectVoiceInput;
|
||||
logger.debug({ guildId, channelId }, "Connecting to voice channel");
|
||||
const status = await connectVoice(guildId, channelId);
|
||||
res.json(status);
|
||||
@@ -49,15 +37,7 @@ export const handleDisconnectVoice = asyncHandler(
|
||||
|
||||
export const handleVoiceCommand = asyncHandler(
|
||||
async (req: Request, res: Response) => {
|
||||
const command = asString(req.body.command);
|
||||
|
||||
if (!command) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "command is required",
|
||||
});
|
||||
}
|
||||
|
||||
const { command } = req.body as VoiceCommandInput;
|
||||
logger.debug({ command }, "Publishing voice command");
|
||||
await publishCommandNoReply(command);
|
||||
res.json({ success: true, command });
|
||||
|
||||
@@ -1,26 +1,80 @@
|
||||
import type { Router } from "express";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
||||
import {
|
||||
handleConnectVoice,
|
||||
handleDisconnectVoice,
|
||||
handleGetVoiceStatus,
|
||||
handleVoiceCommand,
|
||||
} from "./voice.controller.js";
|
||||
import { connectVoiceSchema, voiceCommandSchema } from "./voice.schema.js";
|
||||
import {
|
||||
getGuilds,
|
||||
getTextChannels,
|
||||
getVoiceChannels,
|
||||
} from "./voice.service.js";
|
||||
|
||||
const logger = createChildLogger("voice.routes");
|
||||
|
||||
export function createVoiceRouter(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// ── Guilds ──────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/guilds
|
||||
router.get(
|
||||
"/guilds",
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
logger.debug("Fetching guilds");
|
||||
const guilds = await getGuilds();
|
||||
res.json(guilds);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /api/guilds/:guildId/channels
|
||||
router.get(
|
||||
"/guilds/:guildId/channels",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = req.params.guildId as string;
|
||||
logger.debug({ guildId }, "Fetching text channels");
|
||||
const channels = await getTextChannels(guildId);
|
||||
res.json(channels);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /api/guilds/:guildId/voice-channels
|
||||
router.get(
|
||||
"/guilds/:guildId/voice-channels",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const guildId = req.params.guildId as string;
|
||||
logger.debug({ guildId }, "Fetching voice channels");
|
||||
const channels = await getVoiceChannels(guildId);
|
||||
res.json(channels);
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Voice connection ────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/voice/status
|
||||
router.get("/voice/status", handleGetVoiceStatus);
|
||||
|
||||
// POST /api/voice/connect
|
||||
router.post("/voice/connect", handleConnectVoice);
|
||||
router.post(
|
||||
"/voice/connect",
|
||||
validateBody(connectVoiceSchema),
|
||||
handleConnectVoice,
|
||||
);
|
||||
|
||||
// POST /api/voice/disconnect
|
||||
router.post("/voice/disconnect", handleDisconnectVoice);
|
||||
|
||||
// POST /api/voice/command — send arbitrary voice command (transmit start/stop)
|
||||
router.post("/voice/command", handleVoiceCommand);
|
||||
router.post(
|
||||
"/voice/command",
|
||||
validateBody(voiceCommandSchema),
|
||||
handleVoiceCommand,
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const connectVoiceSchema = z.object({
|
||||
guildId: z.string().min(1, "guildId is required"),
|
||||
channelId: z.string().min(1, "channelId is required"),
|
||||
});
|
||||
|
||||
export const voiceCommandSchema = z.object({
|
||||
command: z.string().min(1, "command is required"),
|
||||
});
|
||||
|
||||
export type ConnectVoiceInput = z.infer<typeof connectVoiceSchema>;
|
||||
export type VoiceCommandInput = z.infer<typeof voiceCommandSchema>;
|
||||
@@ -5,13 +5,15 @@ import {
|
||||
COMMAND_VOICE_CONNECT,
|
||||
COMMAND_VOICE_DISCONNECT,
|
||||
type CommandReply,
|
||||
pgMessagesTable,
|
||||
VOICE_STATUS_KEY,
|
||||
} from "@bete/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createChildLogger,
|
||||
tryCommandThenFallback,
|
||||
} from "../../shared/commandHelper.js";
|
||||
import { getPool } from "../../shared/database/index.js";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||
|
||||
const logger = createChildLogger("voice.service");
|
||||
@@ -78,11 +80,12 @@ export async function getGuilds(): Promise<Guild[]> {
|
||||
return withFallback(
|
||||
() => publishCommand<Guild[]>(COMMAND_GUILDS_LIST, {}),
|
||||
async () => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT guild_id FROM messages ORDER BY guild_id`,
|
||||
);
|
||||
return rows.map((row: Record<string, unknown>) => ({
|
||||
const db = getDatabase();
|
||||
const rows = await db
|
||||
.selectDistinct({ guild_id: pgMessagesTable.guild_id })
|
||||
.from(pgMessagesTable)
|
||||
.orderBy(pgMessagesTable.guild_id);
|
||||
return rows.map((row) => ({
|
||||
id: String(row.guild_id ?? ""),
|
||||
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
|
||||
icon: null,
|
||||
@@ -101,12 +104,13 @@ export async function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||
return withFallback(
|
||||
() => publishCommand<Channel[]>(COMMAND_GUILDS_TEXT_CHANNELS, { guildId }),
|
||||
async () => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT channel_id FROM messages WHERE guild_id = $1 ORDER BY channel_id`,
|
||||
[guildId],
|
||||
);
|
||||
return rows.map((row: Record<string, unknown>) => ({
|
||||
const db = getDatabase();
|
||||
const rows = await db
|
||||
.selectDistinct({ channel_id: pgMessagesTable.channel_id })
|
||||
.from(pgMessagesTable)
|
||||
.where(eq(pgMessagesTable.guild_id, guildId))
|
||||
.orderBy(pgMessagesTable.channel_id);
|
||||
return rows.map((row) => ({
|
||||
id: String(row.channel_id ?? ""),
|
||||
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
|
||||
type: "text" as const,
|
||||
|
||||
Reference in New Issue
Block a user