2026-07-30 11:50:48 +07:00
import { createChildLogger } from "@/shared/logger/index" ;
2026-06-03 18:30:18 +07:00
import { config } from "../../shared/config/index.js" ;
2026-06-09 10:16:04 +07:00
import type {
2026-07-28 15:00:29 +07:00
ChatbotContext ,
ChatbotHistoryRow ,
2026-06-09 10:16:04 +07:00
SaveConversationInput ,
2026-07-28 15:00:29 +07:00
} from "./chatbot.repository.js" ;
import { chatbotRepository } from "./chatbot.repository.js" ;
2026-08-16 09:22:20 +07:00
import { tools } from "./chatbot.toolDefs.js" ;
import { executeTool } from "./chatbot.tools.js" ;
2026-06-03 15:01:34 +07:00
2026-07-28 15:00:29 +07:00
const logger = createChildLogger ( "chatbot.service" );
2026-06-03 15:01:34 +07:00
2026-07-28 15:00:29 +07:00
class ChatbotService {
2026-06-03 15:01:34 +07:00
async processMessage (
message : string ,
2026-07-28 15:00:29 +07:00
context : ChatbotContext | undefined ,
2026-06-03 15:01:34 +07:00
userId : string ,
) : Promise < string > {
2026-06-09 17:34:18 +07:00
logger . info (
2026-08-16 09:15:39 +07:00
{ userId , messageLength : message.length , context },
2026-06-09 17:34:18 +07:00
"processMessage called" ,
);
2026-06-03 15:01:34 +07:00
const recentContext = await this . getRecentConversationContext ( userId );
2026-08-16 09:15:39 +07:00
// Scope the agent to the server/channel the user is chatting in. We no
// longer bake server stats into the prompt — the model must pull current
// data via tools (see buildSystemPrompt), so it always answers from live
// numbers instead of a stale snapshot.
const scope = {
guildId : context?.guildId ,
channelId : context?.channelId ,
};
2026-06-03 18:30:18 +07:00
2026-08-16 09:15:39 +07:00
const systemPrompt = this . buildSystemPrompt ( scope );
2026-06-03 18:30:18 +07:00
const conversationHistory = this . buildHistoryMessages ( recentContext );
const llmResponse = await this . callLLM (
systemPrompt ,
conversationHistory ,
message ,
2026-08-16 09:15:39 +07:00
scope ,
2026-06-03 18:30:18 +07:00
);
return llmResponse ;
2026-06-03 15:01:34 +07:00
}
async saveConversation ( input : SaveConversationInput ) : Promise < void > {
2026-06-09 17:34:18 +07:00
logger . info ({ userId : input.userId }, "saveConversation called" );
2026-07-28 15:00:29 +07:00
await chatbotRepository . saveConversation ( input );
2026-06-03 15:01:34 +07:00
}
async getChatHistory (
userId : string ,
limit : number ,
2026-07-28 15:00:29 +07:00
) : Promise < ChatbotHistoryRow [] > {
2026-06-09 17:34:18 +07:00
logger . debug ({ userId , limit }, "getChatHistory called" );
2026-07-28 15:00:29 +07:00
return chatbotRepository . getChatHistory ( userId , limit );
2026-06-03 15:01:34 +07:00
}
async clearChatHistory ( userId : string ) : Promise < void > {
2026-06-09 17:34:18 +07:00
logger . info ({ userId }, "clearChatHistory called" );
2026-07-28 15:00:29 +07:00
await chatbotRepository . clearChatHistory ( userId );
2026-06-03 15:01:34 +07:00
}
2026-06-03 18:30:18 +07:00
private async getRecentConversationContext (
userId : string ,
) : Promise < string [] > {
2026-07-28 15:00:29 +07:00
const history = await chatbotRepository . getChatHistory ( userId , 3 );
2026-06-03 15:01:34 +07:00
return history . flatMap (( row ) => [
`User: ${ row . user_message } ` ,
2026-07-28 15:00:29 +07:00
`Bot: ${ row . bot_response } ` ,
2026-06-03 15:01:34 +07:00
]);
}
2026-08-16 09:15:39 +07:00
private buildSystemPrompt ( scope : {
guildId? : string ;
channelId? : string ;
2026-06-03 18:30:18 +07:00
}) : string {
2026-08-16 09:15:39 +07:00
const scopeLine = scope . guildId
? `- Scope: kamu menjawab soal server/guild id=" ${ scope . guildId } " ${ scope . channelId ? `, channel id=" ${ scope . channelId } "` : "" } .`
: "- Scope: tidak ada guild spesifik — jawab umum soal server ini." ;
return `Kamu adalah chatbot Discord Watcher — temen ngobrol yang tau keadaan server, dan kamu PUNYA AKSES ke data server lewat tools.
2026-06-03 18:30:18 +07:00
2026-08-16 09:15:39 +07:00
${ scopeLine }
ATURAN PENTING — JANGAN PAKAI KONTEKS STATIS:
- Kamu TIDAK punya hafalan soal angka server (jumlah pesan, user aktif, flagged, dll). JANGAN tebak atau karang angka.
- Untuk SEMUA pertanyaan soal data server (jumlah pesan, user aktif, channel ramai, aktivitas terbaru, pesan di-flag), WAJIB panggil tool yang sesuai (get_server_stats, get_top_channels, get_recent_activity, get_top_flagged). Jawab HANYA dari hasil tool.
- Tool otomatis di-scope ke guild/channel di atas — kalau argumen guildId/channelId kosong, biarkan kosong (sudah otomatis ter-isi). Jangan isi ID yang kamu tebak.
- Kalau tool balas error atau kosong, bilang aja data lagi ga ketemu, jangan karang.
2026-06-03 18:30:18 +07:00
2026-06-03 18:52:20 +07:00
Gaya ngobrol:
- Santai, hangat, kayak ngobrol sama temen
- Pake Bahasa Indonesia sehari-hari, ga perlu kaku
- Sesekali pake emoji wajar aja, ga berlebihan
2026-08-16 09:15:39 +07:00
- Kalo ditanya di luar data server dan kamu ga tau, bilang aja terus tanya balik biar ngobrolnya jalan
- Jangan sebut "rule", "instruksi", "prompt", "tool", atau apapun soal cara kamu berpikir
2026-06-03 18:52:20 +07:00
- Biasa aja, ga usaha lucu-lucu amat — natural` ;
2026-06-03 18:30:18 +07:00
}
2026-06-03 18:52:20 +07:00
private buildHistoryMessages (
recentContext : string [],
) : Array < { role : "user" | "assistant" ; content : string } > {
2026-07-28 15:00:29 +07:00
// recentContext is alternating User/Bot messages
2026-06-03 18:30:18 +07:00
return recentContext . map (( text ) => {
if ( text . startsWith ( "User: " )) {
return { role : "user" as const , content : text.slice ( 6 ) };
}
return { role : "assistant" as const , content : text.slice ( 7 ) };
});
}
private async callLLM (
systemPrompt : string ,
2026-06-03 18:52:20 +07:00
history : Array < { role : "user" | "assistant" ; content : string } > ,
2026-06-03 18:30:18 +07:00
userMessage : string ,
2026-08-16 09:15:39 +07:00
scope : { guildId? : string ; channelId? : string },
2026-06-03 18:30:18 +07:00
) : Promise < string > {
const apiKey = config . AI_LLM_API_KEY ;
const baseUrl = config . AI_LLM_BASE_URL ;
const model = config . AI_LLM_MODEL ;
if ( ! apiKey ) {
logger . warn ( "AI_LLM_API_KEY not configured, using fallback response" );
return this . fallbackResponse ( userMessage );
}
try {
const { default : axios } = await import ( "axios" );
2026-08-03 06:24:19 +07:00
// Gateway tidak handle role system — gabung konteks ke user message.
// The system section stays visible to the model as the first user turn.
2026-06-03 18:52:20 +07:00
const contextPrefixed = ` ${ systemPrompt } \ n \ nPertanyaan user: ${ userMessage } ` ;
2026-08-03 06:24:19 +07:00
// Seed conversation: prior turns + current question.
const messages : Array <
| { role : "user" | "assistant" ; content : string }
| {
role : "assistant" ;
content : string | null ;
tool_calls : Array < {
id : string ;
type : "function" ;
function : { name : string ; arguments : string };
} > ;
}
| { role : "tool" ; tool_call_id : string ; content : string }
> = [... history , { role : "user" , content : contextPrefixed }];
2026-06-03 18:30:18 +07:00
2026-08-03 06:24:19 +07:00
// ── Agentic tool loop ─────────────────────────────────────────
const MAX_TOOL_ROUNDS = 4 ;
for ( let round = 0 ; round <= MAX_TOOL_ROUNDS ; round += 1 ) {
const response = await axios . post (
` ${ baseUrl } /chat/completions` ,
{
model ,
messages ,
tools ,
tool_choice : "auto" ,
max_tokens : 600 ,
temperature : 0.4 ,
stream : true ,
2026-06-03 18:30:18 +07:00
},
2026-08-03 06:24:19 +07:00
{
headers : {
Authorization : `Bearer ${ apiKey } ` ,
"Content-Type" : "application/json" ,
},
timeout : 45_000 ,
// 9router returns SSE even without stream:true; force stream:true
// in the body and read the raw SSE text.
responseType : "text" ,
},
);
2026-06-03 18:30:18 +07:00
2026-08-03 06:24:19 +07:00
// Parse SSE `data:` lines → content + tool_calls.
const { content , toolCalls } = this . parseSse ( response . data as string );
2026-06-03 18:30:18 +07:00
2026-08-03 06:24:19 +07:00
logger . debug (
{
round ,
hasToolCalls : toolCalls.length > 0 ,
toolNames : toolCalls.map (( t ) => t . name ),
},
"LLM round parsed" ,
);
if ( toolCalls . length > 0 ) {
// Execute each tool, append tool results, continue loop.
for ( const tc of toolCalls ) {
messages . push ({
role : "assistant" ,
content : null ,
tool_calls : [
{
id : tc.id ,
type : "function" ,
function : { name : tc.name , arguments : tc.arguments },
},
],
});
2026-08-16 09:15:39 +07:00
// Auto-scope: if the model omitted guildId/channelId, fill them
// from the request scope so tools query the right server without
// the model having to guess IDs.
const scopedArgs = { ... tc . args };
if ( scope . guildId && scopedArgs . guildId == null ) {
scopedArgs . guildId = scope . guildId ;
}
if ( scope . channelId && scopedArgs . channelId == null ) {
scopedArgs . channelId = scope . channelId ;
}
2026-08-03 06:24:19 +07:00
let result = "" ;
try {
2026-08-16 09:15:39 +07:00
result = await executeTool ( tc . name , scopedArgs );
2026-08-03 06:24:19 +07:00
} catch ( e ) {
result = `Tool error: ${ ( e as Error ). message } ` ;
}
messages . push ({
role : "tool" ,
tool_call_id : tc.id ,
content : result ,
});
}
if ( round === MAX_TOOL_ROUNDS ) {
logger . warn ( "Hit max tool rounds; returning what we have" );
}
continue ;
}
if ( content ? . trim ()) {
return content . trim ();
}
logger . warn ( "LLM returned empty response (no tools, no content)" );
return this . fallbackResponse ( userMessage );
2026-06-03 18:30:18 +07:00
}
2026-08-03 06:24:19 +07:00
logger . warn ( "Tool loop exhausted without final content" );
2026-06-03 18:30:18 +07:00
return this . fallbackResponse ( userMessage );
} catch ( error ) {
logger . warn ({ error }, "LLM call failed, using fallback response" );
return this . fallbackResponse ( userMessage );
}
}
2026-08-03 06:24:19 +07:00
/**
* Parse an SSE stream body into accumulated content + any tool_calls.
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines
* even when stream is only implied; we must collect deltas manually.
*/
private parseSse ( body : string ) : {
content : string ;
toolCalls : Array < {
id : string ;
name : string ;
arguments : string ;
args : Record < string , unknown >;
} > ;
} {
const contentParts : string [] = [];
const toolById = new Map <
string ,
{ id : string ; name : string ; arguments : string }
> ();
const lines = body . split ( "\n" );
for ( const rawLine of lines ) {
const line = rawLine . trim ();
if ( ! line . startsWith ( "data:" )) continue ;
const payload = line . slice ( 5 ). trim ();
if ( ! payload || payload === "[DONE]" ) continue ;
try {
const json = JSON . parse ( payload ) as {
choices? : Array < {
delta ?: {
content? : string ;
tool_calls? : Array < {
id? : string ;
index? : number ;
type ?: string ;
function ?: { name? : string ; arguments? : string };
} > ;
};
finish_reason? : string | null ;
} > ;
};
const delta = json . choices ? .[ 0 ] ? . delta ;
if ( ! delta ) continue ;
if ( delta . content ) contentParts . push ( delta . content );
if ( delta . tool_calls ) {
for ( const tc of delta . tool_calls ) {
const idx = String ( tc . index ?? 0 );
const cur = toolById . get ( idx ) ?? {
id : tc.id ?? "" ,
name : "" ,
arguments : "" ,
};
// Keep the first non-empty id for this call index.
if ( tc . id && ! cur . id ) cur . id = tc . id ;
if ( tc . function ? . name ) cur . name += tc . function . name ;
if ( tc . function ? . arguments ) cur . arguments += tc . function . arguments ;
toolById . set ( idx , cur );
}
}
} catch {
// Skip malformed lines (keepalives, etc.)
}
}
// Build a de-duplicated id for any call the stream never assigned one.
let fallbackId = 0 ;
const toolCalls = Array . from ( toolById . values ()). map (( tc ) => {
const id = tc . id || `tool_ ${ fallbackId ++ } _ ${ Date . now () } ` ;
return {
id ,
name : tc.name ,
arguments : tc.arguments ,
args : this.safeJsonParse ( tc . arguments ),
};
});
return { content : contentParts.join ( "" ), toolCalls };
}
private safeJsonParse ( s : string ) : Record < string , unknown > {
try {
return JSON . parse ( s ) as Record < string , unknown >;
} catch {
return {};
}
}
2026-06-03 18:30:18 +07:00
private fallbackResponse ( input : string ) : string {
2026-06-03 15:01:34 +07:00
const lower = input . toLowerCase ();
2026-06-03 18:30:18 +07:00
if (
lower . includes ( "halo" ) ||
lower . includes ( "hai" ) ||
lower . includes ( "hi" ) ||
lower . includes ( "pagi" ) ||
lower . includes ( "siang" ) ||
lower . includes ( "malam" )
) {
2026-06-03 18:52:20 +07:00
return "Halo! 👋 Lagi offline bentar, coba chat lagi nanti ya." ;
2026-06-03 15:01:34 +07:00
}
2026-06-03 18:52:20 +07:00
return "Maaf, lagi ada masalah koneksi. Coba tanya lagi nanti!" ;
2026-06-03 15:01:34 +07:00
}
}
2026-07-28 15:00:29 +07:00
export const chatbotService = new ChatbotService ();