feat(chatbot): expand tool set to cover all server-watcher situations

The chatbot agent now has 14 tools (was 4) so it can answer about ANY
server situation from live data instead of a static snapshot:

- get_server_stats (now also returns clean count)
- get_top_channels, get_recent_activity, get_top_flagged
- search_messages (LIKE keyword search)
- get_user_messages, get_user_profile, get_user_reputation
- get_channel_culture
- get_message_detail (full AI analysis of one message)
- get_message_reviews (human moderation queue by status)
- get_voice_recordings (with transcriptions)
- get_moderation_timeline (daily flagged/warn/clean trend)
- get_corrections (AI false-positive correction history)

Security/quality:
- Every executor now uses parameterized drizzle queries (eq/like/and).
  The old code interpolated model-supplied IDs into sql.raw() — a SQL
  injection vector. Removed.
- Split static tool *definitions* into chatbot.toolDefs.ts (no DB import)
  so the LLM-facing schema can be unit-tested without loading the
  database/config layer. chatbot.tools.ts keeps only the executor.

Verified: tsc + biome clean, 40 backend tests pass (4 new covering the
tool-contract: names unique, required args declared, full situation
coverage).

Co-Authored-By: Claude Opus 5 (Nous Research)
This commit is contained in:
asepharyana
2026-08-16 09:22:20 +07:00
co-authored by Claude Opus 5 (Nous Research)
parent 30828a5534
commit b67856462f
4 changed files with 722 additions and 177 deletions
@@ -6,7 +6,8 @@ import type {
SaveConversationInput, SaveConversationInput,
} from "./chatbot.repository.js"; } from "./chatbot.repository.js";
import { chatbotRepository } from "./chatbot.repository.js"; import { chatbotRepository } from "./chatbot.repository.js";
import { executeTool, tools } from "./chatbot.tools.js"; import { tools } from "./chatbot.toolDefs.js";
import { executeTool } from "./chatbot.tools.js";
const logger = createChildLogger("chatbot.service"); const logger = createChildLogger("chatbot.service");
@@ -0,0 +1,270 @@
/**
* Static tool *definitions* for the chatbot LLM (OpenAI function-calling
* format). Kept separate from the executor (chatbot.tools.ts) so the schema
* the model depends on can be imported without pulling in the database /
* config layer.
*
* The chatbot is a server-watcher agent: it can answer about ANY server
* situation — activity, moderation queue, specific users, channels, voice
* recordings, AI correction history, and trends over time — by calling these
* tools, which the executor implements against real tables.
*/
export interface ToolDef {
type: "function";
function: {
name: string;
description: string;
parameters: {
type: "object";
properties: Record<string, unknown>;
required?: string[];
};
};
}
export const tools: ToolDef[] = [
{
type: "function",
function: {
name: "get_server_stats",
description:
"Ambil statistik ringkas server/guild: total pesan, user aktif, jumlah pesan flagged, warn, dan clean. Panggil untuk jawab pertanyaan umum soal kondisi server. guildId/channelId otomatis ter-isi dari scope; kosongkan untuk semua data.",
parameters: {
type: "object",
properties: {
guildId: { type: "string", description: "ID server (opsional)." },
channelId: { type: "string", description: "ID channel (opsional)." },
},
},
},
},
{
type: "function",
function: {
name: "get_top_channels",
description:
"Ambil daftar channel paling aktif (jumlah pesan terbanyak). Panggil untuk 'channel mana paling ramai' atau aktivitas per-channel.",
parameters: {
type: "object",
properties: {
guildId: { type: "string", description: "ID server (opsional)." },
limit: {
type: "number",
description: "Jumlah channel teratas (default 5, max 10).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_recent_activity",
description:
"Ambil pesan terbaru di server: siapa, di channel mana, jam berapa, isinya. Panggil untuk 'lagi ngapain' / aktivitas terbaru.",
parameters: {
type: "object",
properties: {
guildId: { type: "string", description: "ID server (opsional)." },
channelId: { type: "string", description: "ID channel (opsional)." },
limit: {
type: "number",
description: "Jumlah pesan terakhir (default 5, max 20).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_top_flagged",
description:
"Ambil pesan dengan ai_status flagged (beserta alasan, severity, analysis). Panggil untuk bahas pesan bermasalah / kerjaan moderator.",
parameters: {
type: "object",
properties: {
guildId: { type: "string", description: "ID server (opsional)." },
channelId: { type: "string", description: "ID channel (opsional)." },
limit: { type: "number", description: "Jumlah pesan (default 5)." },
},
},
},
},
{
type: "function",
function: {
name: "search_messages",
description:
"Cari pesan berdasarkan kata kunci di isi pesan (case-insensitive, LIKE). Untuk 'ada yang bahas X gak?' / temukan topik tertentu. Hindari kata terlalu umum.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "Kata kunci pencarian (wajib).",
},
guildId: { type: "string", description: "ID server (opsional)." },
channelId: { type: "string", description: "ID channel (opsional)." },
limit: { type: "number", description: "Jumlah hasil (default 5)." },
},
required: ["query"],
},
},
},
{
type: "function",
function: {
name: "get_user_messages",
description:
"Ambil pesan terbaru dari satu user tertentu (user_id), opsional di-scope ke guild/channel. Untuk 'chat si A gimana akhir-akhir ini?' — butuh user_id.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "ID user (wajib)." },
guildId: { type: "string", description: "ID server (opsional)." },
channelId: { type: "string", description: "ID channel (opsional)." },
limit: { type: "number", description: "Jumlah pesan (default 10)." },
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "get_user_profile",
description:
"Ambil ringkasan profil AI dari seorang user (pola perilaku, gaya bicara) dari tabel user_profiles. Untuk 'siapa si A?' / konteks perilaku. Butuh user_id.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "ID user (wajib)." },
guildId: { type: "string", description: "ID server (opsional)." },
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "get_user_reputation",
description:
"Ambil skor trust, jumlah infraction, dan streak pesan bersih seorang user dari user_reputations. Untuk 'berapa trust score si A?' / riwayat pelanggaran. Butuh user_id.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "ID user (wajib)." },
guildId: { type: "string", description: "ID server (opsional)." },
},
required: ["userId"],
},
},
},
{
type: "function",
function: {
name: "get_channel_culture",
description:
"Ambil ringkasan norma/slang channel dari tabel channel_cultures (AI-generated). Untuk 'norma channel ini gimana?' / konteks sebelum nge-flag. Butuh channel_id.",
parameters: {
type: "object",
properties: {
channelId: { type: "string", description: "ID channel (wajib)." },
},
required: ["channelId"],
},
},
},
{
type: "function",
function: {
name: "get_message_detail",
description:
"Ambil 1 pesan lengkap beserta hasil analisis AI-nya (status, flags, score, severity, kategori, analysis, recommended action). Untuk jelasin keputusan moderasi pada pesan tertentu. Butuh message_id.",
parameters: {
type: "object",
properties: {
messageId: { type: "string", description: "ID pesan (wajib)." },
},
required: ["messageId"],
},
},
},
{
type: "function",
function: {
name: "get_message_reviews",
description:
"Ambil antrean review moderasi manual (message_reviews) berdasarkan status: pending/approved/rejected/escalated. Untuk 'ada review moderasi pending?' / cek kerjaan human moderator. guildId otomatis ter-isi.",
parameters: {
type: "object",
properties: {
guildId: { type: "string", description: "ID server (opsional)." },
status: {
type: "string",
description:
"Status review: pending / approved / rejected / escalated (opsional, default semua).",
},
limit: { type: "number", description: "Jumlah (default 10)." },
},
},
},
},
{
type: "function",
function: {
name: "get_voice_recordings",
description:
"Ambil rekaman suara terbaru (voice_recordings): user, channel, transkripsi, status upload. Untuk 'ada rekaman suara terbaru?' / cek transkripsi. Bisa di-scope ke user_id atau channel_id.",
parameters: {
type: "object",
properties: {
userId: { type: "string", description: "Filter user (opsional)." },
channelId: {
type: "string",
description: "Filter channel (opsional).",
},
guildId: { type: "string", description: "ID server (opsional)." },
limit: { type: "number", description: "Jumlah (default 10)." },
},
},
},
},
{
type: "function",
function: {
name: "get_moderation_timeline",
description:
"Ambil tren harian: per hari, jumlah total pesan vs flagged vs warn vs clean. Untuk 'minggu ini pelanggaran naik?' / lihat tren moderasi. guildId otomatis ter-isi.",
parameters: {
type: "object",
properties: {
guildId: { type: "string", description: "ID server (opsional)." },
channelId: { type: "string", description: "ID channel (opsional)." },
days: {
type: "number",
description: "Jumlah hari ke belakang (default 14, max 60).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_corrections",
description:
"Ambil riwayat koreksi false-positive AI (corrected_moderations): pesan yang awalnya di-flag tapi dikoreksi manusia, beserta alasannya. Untuk 'AI pernah salah nge-flag apa aja?' / audit akurasi moderasi.",
parameters: {
type: "object",
properties: {
guildId: { type: "string", description: "ID server (opsional)." },
limit: { type: "number", description: "Jumlah (default 10)." },
},
},
},
},
];
@@ -1,116 +1,26 @@
import { sql } from "drizzle-orm"; import { and, desc, eq, like, sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js"; import { getDatabase } from "../../shared/database/index.js";
import {
pgChannelCulturesTable,
pgCorrectedModerationsTable,
pgMessageReviewsTable,
pgMessagesTable,
pgUserProfilesTable,
pgUserReputationsTable,
pgVoiceRecordingsTable,
} from "../../shared/index.js";
import { tools } from "./chatbot.toolDefs.js";
/** /**
* Tools the chatbot LLM can call. Definitions describe the schema to the * Executor for the chatbot's server-watcher tools. The tool *definitions*
* model; the executor implements each one against the real database. * live in chatbot.toolDefs.ts (no DB import); this file implements each one
* This turns the chatbot from "blind stats guesser" into an agent that * against the real database.
* pulls real, current server data on demand. *
* 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; export type ToolResult = string;
/** JSON schema for a tool definition (OpenAI function-calling format). */
export interface ToolDef {
type: "function";
function: {
name: string;
description: string;
parameters: {
type: "object";
properties: Record<string, unknown>;
required?: string[];
};
};
}
export const tools: ToolDef[] = [
{
type: "function",
function: {
name: "get_server_stats",
description:
"Ambil statistik ringkas server/guild saat ini: total pesan, user aktif, jumlah pesan flagged, dan jumlah warning. Panggil ini untuk menjawab pertanyaan umum tentang kondisi server. Opsional fill guild_id untuk scope ke guild tertentu, channel_id untuk scope ke channel.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID guild/server (opsional). Kosongkan = semua data.",
},
channelId: {
type: "string",
description: "ID channel (opsional).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_top_channels",
description:
"Ambil daftar channel paling aktif (jumlah pesan terbanyak) di server. Panggil buat jawab 'channel mana paling ramai' atau aktivitas per-channel.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID server (opsional).",
},
limit: {
type: "number",
description: "Jumlah channel teratas (default 5, max 10).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_recent_activity",
description:
"Ambil aktivitas/pesan terbaru di server: siapa yang baru ngomong, di channel mana, jam berapa. Panggil buat jawaban soal 'lagi ngapain' / aktivitas terbaru di server.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID server (opsional).",
},
limit: {
type: "number",
description: "Jumlah pesan terakhir (default 5).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_top_flagged",
description:
"Ambil pesan yang paling sering di-flag atau kena warning. Panggil buat jawab soal pesan bermasalah / moderator.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID server (opsional).",
},
limit: {
type: "number",
description: "Jumlah pesan (default 5).",
},
},
},
},
},
];
/** Executes a tool call against the real DB and returns a readable result. */ /** Executes a tool call against the real DB and returns a readable result. */
export async function executeTool( export async function executeTool(
name: string, name: string,
@@ -122,9 +32,11 @@ export async function executeTool(
typeof args.channelId === "string" && args.channelId typeof args.channelId === "string" && args.channelId
? args.channelId ? args.channelId
: undefined; : undefined;
const userId =
typeof args.userId === "string" && args.userId ? args.userId : undefined;
const limitRaw = const limitRaw =
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5; typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 10); const limit = Math.min(Math.max(1, Math.round(limitRaw)), 20);
try { try {
switch (name) { switch (name) {
@@ -133,9 +45,48 @@ export async function executeTool(
case "get_top_channels": case "get_top_channels":
return await topChannels(guildId, limit); return await topChannels(guildId, limit);
case "get_recent_activity": case "get_recent_activity":
return await recentActivity(guildId, limit); return await recentActivity(guildId, channelId, limit);
case "get_top_flagged": case "get_top_flagged":
return await topFlagged(guildId, limit); 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: default:
return `Unknown tool: ${name}`; return `Unknown tool: ${name}`;
} }
@@ -145,6 +96,23 @@ export async function executeTool(
} }
} }
// ── 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 ────────────────────────────────────────── // ── Tool executors ──────────────────────────────────────────
async function serverStats( async function serverStats(
@@ -152,81 +120,330 @@ async function serverStats(
channelId?: string, channelId?: string,
): Promise<string> { ): Promise<string> {
const db = getDatabase(); const db = getDatabase();
const conditions: string[] = []; const [result] = await db
if (guildId) conditions.push(`guild_id = '${guildId}'`); .select({
if (channelId) conditions.push(`channel_id = '${channelId}'`); total_messages: sql<number>`COUNT(*)::int`,
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; 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 result = await db.execute( const r = result ?? {
sql.raw( total_messages: 0,
`SELECT COUNT(*)::int AS total_messages, active_users: 0,
COUNT(DISTINCT user_id)::int AS active_users, flagged: 0,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged, warned: 0,
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned clean: 0,
FROM messages ${cond}`, };
), return JSON.stringify(r);
);
const rows =
(result as unknown as { rows: Record<string, unknown>[] }).rows ?? [];
const r = rows[0] ?? {};
return JSON.stringify({
total_messages: r.total_messages ?? 0,
active_users: r.active_users ?? 0,
flagged: r.flagged ?? 0,
warned: r.warned ?? 0,
});
} }
async function topChannels(guildId?: string, limit = 5): Promise<string> { async function topChannels(guildId?: string, limit = 5): Promise<string> {
const db = getDatabase(); const db = getDatabase();
const conditions: string[] = []; const rows = await db
if (guildId) conditions.push(`guild_id = '${guildId}'`); .select({
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; channel_id: pgMessagesTable.channel_id,
count: sql<number>`COUNT(*)::int`,
const result = await db.execute( })
sql.raw( .from(pgMessagesTable)
`SELECT channel_id, .where(scopeMessages(guildId))
COUNT(*)::int AS count .groupBy(pgMessagesTable.channel_id)
FROM messages ${cond} .orderBy(desc(sql`COUNT(*)`))
GROUP BY channel_id .limit(limit);
ORDER BY count DESC return JSON.stringify(rows);
LIMIT ${limit}`,
),
);
const rows = (result as unknown as { rows: unknown[] }).rows ?? [];
return JSON.stringify(rows.slice(0, limit));
} }
async function recentActivity(guildId?: string, limit = 5): Promise<string> { async function recentActivity(
guildId?: string,
channelId?: string,
limit = 5,
): Promise<string> {
const db = getDatabase(); const db = getDatabase();
const conditions: string[] = []; const rows = await db
if (guildId) conditions.push(`guild_id = '${guildId}'`); .select({
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; id: pgMessagesTable.id,
username: pgMessagesTable.username,
const result = await db.execute( user_id: pgMessagesTable.user_id,
sql.raw( channel_id: pgMessagesTable.channel_id,
`SELECT username, content, channel_id, created_at content: pgMessagesTable.content,
FROM messages ${cond} created_at: pgMessagesTable.created_at,
ORDER BY created_at DESC ai_status: pgMessagesTable.ai_status,
LIMIT ${limit}`, })
), .from(pgMessagesTable)
); .where(scopeMessages(guildId, channelId))
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []); .orderBy(desc(pgMessagesTable.created_at))
.limit(limit);
return JSON.stringify(rows);
} }
async function topFlagged(guildId?: string, limit = 5): Promise<string> { async function topFlagged(
guildId?: string,
channelId?: string,
limit = 5,
): Promise<string> {
const db = getDatabase(); const db = getDatabase();
const conditions = ["ai_status IN ('flagged', 'warn')"]; const rows = await db
if (guildId) conditions.push(`guild_id = '${guildId}'`); .select({
const cond = `WHERE ${conditions.join(" AND ")}`; id: pgMessagesTable.id,
username: pgMessagesTable.username,
const result = await db.execute( channel_id: pgMessagesTable.channel_id,
sql.raw( content: pgMessagesTable.content,
`SELECT username, content, channel_id, ai_status, created_at ai_status: pgMessagesTable.ai_status,
FROM messages ${cond} ai_severity: pgMessagesTable.ai_severity,
ORDER BY created_at DESC ai_moderation_flags: pgMessagesTable.ai_moderation_flags,
LIMIT ${limit}`, ai_analysis: pgMessagesTable.ai_analysis,
), created_at: pgMessagesTable.created_at,
); })
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []); .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);
} }
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { tools } from "../src/modules/chatbot/chatbot.toolDefs.js";
const names = tools.map((t) => t.function.name);
describe("chatbot tool definitions", () => {
it("exposes a stable, non-empty tool set", () => {
expect(tools.length).toBeGreaterThanOrEqual(10);
expect(new Set(names).size).toBe(names.length); // no dup names
});
it("every tool declares a name, description, and object parameters", () => {
for (const t of tools) {
expect(t.type).toBe("function");
expect(typeof t.function.name).toBe("string");
expect(t.function.description.length).toBeGreaterThan(10);
expect(t.function.parameters.type).toBe("object");
}
});
it("required-only tools declare required args", () => {
const byName = new Map(tools.map((t) => [t.function.name, t]));
for (const [name, required] of [
["search_messages", "query"],
["get_user_messages", "userId"],
["get_user_profile", "userId"],
["get_user_reputation", "userId"],
["get_channel_culture", "channelId"],
["get_message_detail", "messageId"],
] as const) {
const tool = byName.get(name);
expect(tool, `missing tool ${name}`).toBeDefined();
expect(tool!.function.parameters.required).toContain(required);
}
});
it("covers the core server-watcher situations", () => {
for (const required of [
"get_server_stats",
"get_top_channels",
"get_recent_activity",
"get_top_flagged",
"search_messages",
"get_user_messages",
"get_user_profile",
"get_user_reputation",
"get_channel_culture",
"get_message_detail",
"get_message_reviews",
"get_voice_recordings",
"get_moderation_timeline",
"get_corrections",
]) {
expect(names, `missing ${required}`).toContain(required);
}
});
});