449 lines
15 KiB
TypeScript
449 lines
15 KiB
TypeScript
import { and, desc, eq, like, sql } from "drizzle-orm";
|
|
import { getDatabase } from "../../shared/database/index.js";
|
|
import {
|
|
pgChannelCulturesTable,
|
|
pgCorrectedModerationsTable,
|
|
pgMessageReviewsTable,
|
|
pgMessagesTable,
|
|
pgUserProfilesTable,
|
|
pgUserReputationsTable,
|
|
pgVoiceRecordingsTable,
|
|
} from "../../shared/index.js";
|
|
|
|
/**
|
|
* Executor for the chatbot's server-watcher tools. The tool *definitions*
|
|
* live in chatbot.toolDefs.ts (no DB import); this file implements each one
|
|
* against the real database.
|
|
*
|
|
* All queries use parameterized drizzle operators (eq/like/and) — never string
|
|
* interpolation into raw SQL — so model-supplied arguments cannot inject SQL.
|
|
*/
|
|
|
|
export type ToolResult = string;
|
|
/** Executes a tool call against the real DB and returns a readable result. */
|
|
export async function executeTool(
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
): Promise<string> {
|
|
const guildId =
|
|
typeof args.guildId === "string" && args.guildId ? args.guildId : undefined;
|
|
const channelId =
|
|
typeof args.channelId === "string" && args.channelId
|
|
? args.channelId
|
|
: undefined;
|
|
const userId =
|
|
typeof args.userId === "string" && args.userId ? args.userId : undefined;
|
|
const limitRaw =
|
|
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
|
|
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 20);
|
|
|
|
try {
|
|
switch (name) {
|
|
case "get_server_stats":
|
|
return await serverStats(guildId, channelId);
|
|
case "get_top_channels":
|
|
return await topChannels(guildId, limit);
|
|
case "get_recent_activity":
|
|
return await recentActivity(guildId, channelId, limit);
|
|
case "get_top_flagged":
|
|
return await topFlagged(guildId, channelId, limit);
|
|
case "search_messages":
|
|
return await searchMessages(
|
|
String(args.query ?? ""),
|
|
guildId,
|
|
channelId,
|
|
limit,
|
|
);
|
|
case "get_user_messages":
|
|
return await userMessages(userId, guildId, channelId, limit);
|
|
case "get_user_profile":
|
|
return await userProfile(userId, guildId);
|
|
case "get_user_reputation":
|
|
return await userReputation(userId, guildId);
|
|
case "get_channel_culture":
|
|
return await channelCulture(
|
|
typeof args.channelId === "string" ? args.channelId : undefined,
|
|
);
|
|
case "get_message_detail":
|
|
return await messageDetail(
|
|
typeof args.messageId === "string" ? args.messageId : undefined,
|
|
);
|
|
case "get_message_reviews":
|
|
return await messageReviews(
|
|
guildId,
|
|
typeof args.status === "string" ? args.status : undefined,
|
|
limit,
|
|
);
|
|
case "get_voice_recordings":
|
|
return await voiceRecordings(userId, channelId, guildId, limit);
|
|
case "get_moderation_timeline":
|
|
return await moderationTimeline(
|
|
guildId,
|
|
channelId,
|
|
typeof args.days === "number"
|
|
? Math.min(Math.max(1, args.days), 60)
|
|
: 14,
|
|
);
|
|
case "get_corrections":
|
|
return await corrections(guildId, limit);
|
|
default:
|
|
return `Unknown tool: ${name}`;
|
|
}
|
|
} catch (error) {
|
|
// Best-effort: if a tool fails, return readable error instead of crashing
|
|
return `Terjadi kesalahan saat ambil data: ${(error as Error).message ?? "unknown"}`;
|
|
}
|
|
}
|
|
|
|
// ── Query helpers ──────────────────────────────────────────
|
|
|
|
function scopeMessages(
|
|
guildId?: string,
|
|
channelId?: string,
|
|
): ReturnType<typeof and> | undefined {
|
|
const conds = [];
|
|
if (guildId) conds.push(eq(pgMessagesTable.guild_id, guildId));
|
|
if (channelId) conds.push(eq(pgMessagesTable.channel_id, channelId));
|
|
return conds.length ? and(...conds) : undefined;
|
|
}
|
|
|
|
/** Escape LIKE wildcards so user input can't break the pattern. */
|
|
function likePattern(q: string): string {
|
|
return q.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
}
|
|
|
|
// ── Tool executors ──────────────────────────────────────────
|
|
|
|
async function serverStats(
|
|
guildId?: string,
|
|
channelId?: string,
|
|
): Promise<string> {
|
|
const db = getDatabase();
|
|
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`,
|
|
clean: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'clean')::int`,
|
|
})
|
|
.from(pgMessagesTable)
|
|
.where(scopeMessages(guildId, channelId));
|
|
|
|
const r = result ?? {
|
|
total_messages: 0,
|
|
active_users: 0,
|
|
flagged: 0,
|
|
warned: 0,
|
|
clean: 0,
|
|
};
|
|
return JSON.stringify(r);
|
|
}
|
|
|
|
async function topChannels(guildId?: string, limit = 5): Promise<string> {
|
|
const db = getDatabase();
|
|
const rows = await db
|
|
.select({
|
|
channel_id: pgMessagesTable.channel_id,
|
|
count: sql<number>`COUNT(*)::int`,
|
|
})
|
|
.from(pgMessagesTable)
|
|
.where(scopeMessages(guildId))
|
|
.groupBy(pgMessagesTable.channel_id)
|
|
.orderBy(desc(sql`COUNT(*)`))
|
|
.limit(limit);
|
|
return JSON.stringify(rows);
|
|
}
|
|
|
|
async function recentActivity(
|
|
guildId?: string,
|
|
channelId?: string,
|
|
limit = 5,
|
|
): Promise<string> {
|
|
const db = getDatabase();
|
|
const rows = await db
|
|
.select({
|
|
id: pgMessagesTable.id,
|
|
username: pgMessagesTable.username,
|
|
user_id: pgMessagesTable.user_id,
|
|
channel_id: pgMessagesTable.channel_id,
|
|
content: pgMessagesTable.content,
|
|
created_at: pgMessagesTable.created_at,
|
|
ai_status: pgMessagesTable.ai_status,
|
|
})
|
|
.from(pgMessagesTable)
|
|
.where(scopeMessages(guildId, channelId))
|
|
.orderBy(desc(pgMessagesTable.created_at))
|
|
.limit(limit);
|
|
return JSON.stringify(rows);
|
|
}
|
|
|
|
async function topFlagged(
|
|
guildId?: string,
|
|
channelId?: string,
|
|
limit = 5,
|
|
): Promise<string> {
|
|
const db = getDatabase();
|
|
const rows = await db
|
|
.select({
|
|
id: pgMessagesTable.id,
|
|
username: pgMessagesTable.username,
|
|
channel_id: pgMessagesTable.channel_id,
|
|
content: pgMessagesTable.content,
|
|
ai_status: pgMessagesTable.ai_status,
|
|
ai_severity: pgMessagesTable.ai_severity,
|
|
ai_moderation_flags: pgMessagesTable.ai_moderation_flags,
|
|
ai_analysis: pgMessagesTable.ai_analysis,
|
|
created_at: pgMessagesTable.created_at,
|
|
})
|
|
.from(pgMessagesTable)
|
|
.where(
|
|
and(
|
|
scopeMessages(guildId, channelId),
|
|
eq(pgMessagesTable.ai_status, "flagged"),
|
|
),
|
|
)
|
|
.orderBy(desc(pgMessagesTable.created_at))
|
|
.limit(limit);
|
|
return JSON.stringify(rows);
|
|
}
|
|
|
|
async function searchMessages(
|
|
query: string,
|
|
guildId?: string,
|
|
channelId?: string,
|
|
limit = 5,
|
|
): Promise<string> {
|
|
const db = getDatabase();
|
|
if (!query.trim()) return JSON.stringify({ error: "query kosong" });
|
|
const rows = await db
|
|
.select({
|
|
id: pgMessagesTable.id,
|
|
username: pgMessagesTable.username,
|
|
channel_id: pgMessagesTable.channel_id,
|
|
content: pgMessagesTable.content,
|
|
created_at: pgMessagesTable.created_at,
|
|
ai_status: pgMessagesTable.ai_status,
|
|
})
|
|
.from(pgMessagesTable)
|
|
.where(
|
|
and(
|
|
scopeMessages(guildId, channelId),
|
|
like(pgMessagesTable.content, `%${likePattern(query)}%`),
|
|
),
|
|
)
|
|
.orderBy(desc(pgMessagesTable.created_at))
|
|
.limit(limit);
|
|
return JSON.stringify(rows);
|
|
}
|
|
|
|
async function userMessages(
|
|
userId?: string,
|
|
guildId?: string,
|
|
channelId?: string,
|
|
limit = 10,
|
|
): Promise<string> {
|
|
const db = getDatabase();
|
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
|
const conds = [eq(pgMessagesTable.user_id, userId)];
|
|
if (guildId) conds.push(eq(pgMessagesTable.guild_id, guildId));
|
|
if (channelId) conds.push(eq(pgMessagesTable.channel_id, channelId));
|
|
const rows = await db
|
|
.select({
|
|
id: pgMessagesTable.id,
|
|
channel_id: pgMessagesTable.channel_id,
|
|
content: pgMessagesTable.content,
|
|
created_at: pgMessagesTable.created_at,
|
|
ai_status: pgMessagesTable.ai_status,
|
|
})
|
|
.from(pgMessagesTable)
|
|
.where(and(...conds))
|
|
.orderBy(desc(pgMessagesTable.created_at))
|
|
.limit(limit);
|
|
return JSON.stringify(rows);
|
|
}
|
|
|
|
async function userProfile(userId?: string, guildId?: string): Promise<string> {
|
|
const db = getDatabase();
|
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
|
const conds = [eq(pgUserProfilesTable.user_id, userId)];
|
|
if (guildId) conds.push(eq(pgUserProfilesTable.guild_id, guildId));
|
|
const rows = await db
|
|
.select({
|
|
user_id: pgUserProfilesTable.user_id,
|
|
guild_id: pgUserProfilesTable.guild_id,
|
|
profile_summary: pgUserProfilesTable.profile_summary,
|
|
last_analyzed_at: pgUserProfilesTable.last_analyzed_at,
|
|
})
|
|
.from(pgUserProfilesTable)
|
|
.where(and(...conds))
|
|
.limit(1);
|
|
return JSON.stringify(rows[0] ?? { error: "profil tidak ditemukan" });
|
|
}
|
|
|
|
async function userReputation(
|
|
userId?: string,
|
|
guildId?: string,
|
|
): Promise<string> {
|
|
const db = getDatabase();
|
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
|
const conds = [eq(pgUserReputationsTable.user_id, userId)];
|
|
if (guildId) conds.push(eq(pgUserReputationsTable.guild_id, guildId));
|
|
const rows = await db
|
|
.select({
|
|
user_id: pgUserReputationsTable.user_id,
|
|
guild_id: pgUserReputationsTable.guild_id,
|
|
trust_score: pgUserReputationsTable.trust_score,
|
|
clean_message_streak: pgUserReputationsTable.clean_message_streak,
|
|
total_infractions: pgUserReputationsTable.total_infractions,
|
|
last_infraction_at: pgUserReputationsTable.last_infraction_at,
|
|
})
|
|
.from(pgUserReputationsTable)
|
|
.where(and(...conds))
|
|
.limit(1);
|
|
return JSON.stringify(rows[0] ?? { error: "reputasi tidak ditemukan" });
|
|
}
|
|
|
|
async function channelCulture(channelId?: string): Promise<string> {
|
|
const db = getDatabase();
|
|
if (!channelId) return JSON.stringify({ error: "channelId wajib" });
|
|
const rows = await db
|
|
.select({
|
|
channel_id: pgChannelCulturesTable.channel_id,
|
|
culture_summary: pgChannelCulturesTable.culture_summary,
|
|
last_analyzed_at: pgChannelCulturesTable.last_analyzed_at,
|
|
})
|
|
.from(pgChannelCulturesTable)
|
|
.where(eq(pgChannelCulturesTable.channel_id, channelId))
|
|
.limit(1);
|
|
return JSON.stringify(rows[0] ?? { error: "culture tidak ditemukan" });
|
|
}
|
|
|
|
async function messageDetail(messageId?: string): Promise<string> {
|
|
const db = getDatabase();
|
|
if (!messageId) return JSON.stringify({ error: "messageId wajib" });
|
|
const rows = await db
|
|
.select({
|
|
id: pgMessagesTable.id,
|
|
guild_id: pgMessagesTable.guild_id,
|
|
channel_id: pgMessagesTable.channel_id,
|
|
user_id: pgMessagesTable.user_id,
|
|
username: pgMessagesTable.username,
|
|
content: pgMessagesTable.content,
|
|
created_at: pgMessagesTable.created_at,
|
|
ai_status: pgMessagesTable.ai_status,
|
|
ai_moderation_flags: pgMessagesTable.ai_moderation_flags,
|
|
ai_moderation_score: pgMessagesTable.ai_moderation_score,
|
|
ai_severity: pgMessagesTable.ai_severity,
|
|
ai_categories: pgMessagesTable.ai_categories,
|
|
ai_analysis: pgMessagesTable.ai_analysis,
|
|
ai_recommended_action: pgMessagesTable.ai_recommended_action,
|
|
ai_confidence: pgMessagesTable.ai_confidence,
|
|
})
|
|
.from(pgMessagesTable)
|
|
.where(eq(pgMessagesTable.id, messageId))
|
|
.limit(1);
|
|
return JSON.stringify(rows[0] ?? { error: "pesan tidak ditemukan" });
|
|
}
|
|
|
|
async function messageReviews(
|
|
guildId?: string,
|
|
status?: string,
|
|
limit = 10,
|
|
): Promise<string> {
|
|
const db = getDatabase();
|
|
const conds = [];
|
|
if (guildId) conds.push(eq(pgMessageReviewsTable.guild_id, guildId));
|
|
if (status) conds.push(eq(pgMessageReviewsTable.status, status as never));
|
|
const rows = await db
|
|
.select({
|
|
id: pgMessageReviewsTable.id,
|
|
message_id: pgMessageReviewsTable.message_id,
|
|
reviewer_id: pgMessageReviewsTable.reviewer_id,
|
|
status: pgMessageReviewsTable.status,
|
|
notes: pgMessageReviewsTable.notes,
|
|
created_at: pgMessageReviewsTable.created_at,
|
|
reviewed_at: pgMessageReviewsTable.reviewed_at,
|
|
})
|
|
.from(pgMessageReviewsTable)
|
|
.where(conds.length ? and(...conds) : undefined)
|
|
.orderBy(desc(pgMessageReviewsTable.created_at))
|
|
.limit(limit);
|
|
return JSON.stringify(rows);
|
|
}
|
|
|
|
async function voiceRecordings(
|
|
userId?: string,
|
|
channelId?: string,
|
|
guildId?: string,
|
|
limit = 10,
|
|
): Promise<string> {
|
|
const db = getDatabase();
|
|
const conds = [];
|
|
if (userId) conds.push(eq(pgVoiceRecordingsTable.user_id, userId));
|
|
if (channelId) conds.push(eq(pgVoiceRecordingsTable.channel_id, channelId));
|
|
if (guildId) conds.push(eq(pgVoiceRecordingsTable.guild_id, guildId));
|
|
const rows = await db
|
|
.select({
|
|
id: pgVoiceRecordingsTable.id,
|
|
username: pgVoiceRecordingsTable.username,
|
|
channel_name: pgVoiceRecordingsTable.channel_name,
|
|
filename: pgVoiceRecordingsTable.filename,
|
|
size_bytes: pgVoiceRecordingsTable.size_bytes,
|
|
upload_status: pgVoiceRecordingsTable.upload_status,
|
|
transcription: pgVoiceRecordingsTable.transcription,
|
|
created_at: pgVoiceRecordingsTable.created_at,
|
|
})
|
|
.from(pgVoiceRecordingsTable)
|
|
.where(conds.length ? and(...conds) : undefined)
|
|
.orderBy(desc(pgVoiceRecordingsTable.created_at))
|
|
.limit(limit);
|
|
return JSON.stringify(rows);
|
|
}
|
|
|
|
async function moderationTimeline(
|
|
guildId?: string,
|
|
channelId?: string,
|
|
days = 14,
|
|
): Promise<string> {
|
|
const db = getDatabase();
|
|
const day = sql<string>`to_char(to_timestamp(${pgMessagesTable.created_at} / 1000), 'YYYY-MM-DD')`;
|
|
const rows = await db
|
|
.select({
|
|
day,
|
|
total: sql<number>`COUNT(*)::int`,
|
|
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
|
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
|
clean: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'clean')::int`,
|
|
})
|
|
.from(pgMessagesTable)
|
|
.where(
|
|
and(
|
|
scopeMessages(guildId, channelId),
|
|
// only the last N days
|
|
sql`${pgMessagesTable.created_at} >= extract(epoch FROM now() - (${days} || ' days')::interval) * 1000`,
|
|
),
|
|
)
|
|
.groupBy(day)
|
|
.orderBy(day);
|
|
return JSON.stringify(rows);
|
|
}
|
|
|
|
async function corrections(_guildId?: string, limit = 10): Promise<string> {
|
|
const db = getDatabase();
|
|
const rows = await db
|
|
.select({
|
|
id: pgCorrectedModerationsTable.id,
|
|
message_id: pgCorrectedModerationsTable.message_id,
|
|
original_flags: pgCorrectedModerationsTable.original_flags,
|
|
corrected_flags: pgCorrectedModerationsTable.corrected_flags,
|
|
correction_notes: pgCorrectedModerationsTable.correction_notes,
|
|
content_snippet: pgCorrectedModerationsTable.content_snippet,
|
|
created_at: pgCorrectedModerationsTable.created_at,
|
|
})
|
|
.from(pgCorrectedModerationsTable)
|
|
.orderBy(desc(pgCorrectedModerationsTable.created_at))
|
|
.limit(limit);
|
|
return JSON.stringify(rows);
|
|
}
|