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:
co-authored by
Claude Opus 5 (Nous Research)
parent
30828a5534
commit
b67856462f
@@ -6,7 +6,8 @@ import type {
|
||||
SaveConversationInput,
|
||||
} 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");
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
* model; the executor implements each one against the real database.
|
||||
* This turns the chatbot from "blind stats guesser" into an agent that
|
||||
* pulls real, current server data on demand.
|
||||
* 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;
|
||||
|
||||
/** 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. */
|
||||
export async function executeTool(
|
||||
name: string,
|
||||
@@ -122,9 +32,11 @@ export async function executeTool(
|
||||
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)), 10);
|
||||
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 20);
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
@@ -133,9 +45,48 @@ export async function executeTool(
|
||||
case "get_top_channels":
|
||||
return await topChannels(guildId, limit);
|
||||
case "get_recent_activity":
|
||||
return await recentActivity(guildId, limit);
|
||||
return await recentActivity(guildId, channelId, limit);
|
||||
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:
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
async function serverStats(
|
||||
@@ -152,81 +120,330 @@ async function serverStats(
|
||||
channelId?: string,
|
||||
): Promise<string> {
|
||||
const db = getDatabase();
|
||||
const conditions: string[] = [];
|
||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
||||
if (channelId) conditions.push(`channel_id = '${channelId}'`);
|
||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
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 result = await db.execute(
|
||||
sql.raw(
|
||||
`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 ${cond}`,
|
||||
),
|
||||
);
|
||||
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,
|
||||
});
|
||||
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 conditions: string[] = [];
|
||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const result = await db.execute(
|
||||
sql.raw(
|
||||
`SELECT channel_id,
|
||||
COUNT(*)::int AS count
|
||||
FROM messages ${cond}
|
||||
GROUP BY channel_id
|
||||
ORDER BY count DESC
|
||||
LIMIT ${limit}`,
|
||||
),
|
||||
);
|
||||
const rows = (result as unknown as { rows: unknown[] }).rows ?? [];
|
||||
return JSON.stringify(rows.slice(0, limit));
|
||||
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, limit = 5): Promise<string> {
|
||||
async function recentActivity(
|
||||
guildId?: string,
|
||||
channelId?: string,
|
||||
limit = 5,
|
||||
): Promise<string> {
|
||||
const db = getDatabase();
|
||||
const conditions: string[] = [];
|
||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const result = await db.execute(
|
||||
sql.raw(
|
||||
`SELECT username, content, channel_id, created_at
|
||||
FROM messages ${cond}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit}`,
|
||||
),
|
||||
);
|
||||
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
|
||||
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, limit = 5): Promise<string> {
|
||||
async function topFlagged(
|
||||
guildId?: string,
|
||||
channelId?: string,
|
||||
limit = 5,
|
||||
): Promise<string> {
|
||||
const db = getDatabase();
|
||||
const conditions = ["ai_status IN ('flagged', 'warn')"];
|
||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
||||
const cond = `WHERE ${conditions.join(" AND ")}`;
|
||||
|
||||
const result = await db.execute(
|
||||
sql.raw(
|
||||
`SELECT username, content, channel_id, ai_status, created_at
|
||||
FROM messages ${cond}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit}`,
|
||||
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"),
|
||||
),
|
||||
);
|
||||
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
|
||||
)
|
||||
.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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user