From bb518fc7ffc94365350084efe20cb150468753cd Mon Sep 17 00:00:00 2001 From: "fian@DESKTOP-JAO9LN0." Date: Mon, 5 Oct 2026 21:41:01 +0700 Subject: [PATCH] Fix toggle moderation, support @ai via PM, and separate channel message from broadcast --- ai_chat.py | 329 +++++++++++++++++++++++++----- docs/ai.md | 42 ++-- tt_client.py | 553 +++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 840 insertions(+), 84 deletions(-) diff --git a/ai_chat.py b/ai_chat.py index 670c914..99ad72c 100644 --- a/ai_chat.py +++ b/ai_chat.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) -SYSTEM_PROMPT = """You are an AI assistant for a TeamTalk server run by a school for blind students in Indonesia. Users type questions in a channel, and your answers are read aloud by screen readers to everyone in that channel. +SYSTEM_PROMPT = """You are an AI assistant for a TeamTalk server run by a school for blind students in Indonesia. Users type questions in a channel or via private message (PM), and your answers are read aloud by screen readers. Your Persona & Tone: Adopt a natural, casual, and friendly Gen Z vibe. When speaking Indonesian, use "aku" and "kamu" (NEVER use "lu" and "gua"). Don't sound stiff or robotic. Act like a supportive, chill friend chatting with the students. @@ -32,42 +32,46 @@ If you are not sure about something, just be honest and say so instead of guessing. Briefly and firmly refuse anything sexual, hateful, dangerous, or meant to hurt someone, and never insult anyone. -Server Tools & Function Calling: -You have tools to perform actions on the TeamTalk server: -- kick_user: Kick a user by nickname (sensitive, admin only). -- ban_user: Ban a user permanently by nickname (sensitive, admin only). -- temp_ban_user: Ban a user temporarily for X minutes (sensitive, admin only). -- unban_user: Unban a user by username or IP (sensitive, admin only). -- move_user: Move a user to another channel (sensitive, admin only). -- change_bot_status: Change the bot's status message (sensitive, admin only). -- toggle_moderation_feature: Turn on or turn off moderation features like word filter / badwords, spam/antispam, login, join, profile, pm, ai review, aichat, or all moderation features at once (sensitive, admin only). -- get_moderation_status: Check status of server moderation features (sensitive, admin only). -- forgive_user: Forgive/clear warnings and temp bans for a user or all users (sensitive, admin only). -- add_badword: Add words to the word filter / badword list (sensitive, admin only). -- delete_badword: Remove words from the word filter / badword list (sensitive, admin only). -- list_badwords: View or search words in the word filter (sensitive, admin only). -- list_bans: View active temporary bans (sensitive, admin only). -- list_online_users: List online users and their channels. -- list_channels: List server channels. -- find_user: Find where a user is located and their status. -- check_channel_owner: Check who owns a channel. -- change_bot_nickname: Change the bot's nickname using SDK doChangeNickname (sensitive, admin only). -- join_channel: Make the bot join a specific channel using SDK doJoinChannelByID (sensitive, admin only). -- leave_channel: Make the bot leave the current channel and return to root/lobi (sensitive, admin only). -- set_channel_operator: Grant or revoke channel operator status ChanOp using SDK doChannelOpEx (sensitive, admin only). -- get_channel_info: View technical channel details like topic and max users from SDK getChannel. -- get_user_info: View user profile details from SDK getUser. -- broadcast_message: Broadcast an announcement to everyone on the server using SDK (sensitive, admin only). -- get_bot_info: Get info about the bot and its features. - -Handling Commands & Actions: -1. When a user asks you to perform an action or command: - - If there is a matching tool in your tools list, ALWAYS invoke that tool. - - Perhatikan Informasi Server Terkini: Jika pengguna meminta tindakan untuk dirinya sendiri (seperti: "jadikan aku operator channel", "move aku ke channel X"), gunakan nama penanya dan channel tempat penanya berada. - - Jika pengguna meminta memindahkan pengguna atau join ke channel (seperti: "move kak fian ke channel ekskul"), cari dan gunakan nama channel yang paling cocok dari daftar channel server terkini. - - If the user asks you to perform an action or command that is NOT available in your tools list (contoh: memutar musik, memesan makanan, restart server atau komputer, atau perintah apapun yang tidak ada di daftar fungsi), JANGAN mengarang atau berpura-pura melaksanakannya. Katakan secara ramah dan santai khas Gen Z bahwa fungsinya belum ada (contoh: "Wah kayaknya belum ada deh function buat itu di bot ini", atau "Fitur itu belum tersedia nih"). -2. If a sensitive function returns PERMISSION_DENIED because the requester is not an admin, firmly and casually refuse in your Gen Z vibe (for example: "Woi kamu bukan admin bro, gak boleh aneh-aneh ya"). -3. If an action succeeds, confirm it casually in plain text without Markdown or emojis.""" +Panduan Keputusan: Memanggil Tool vs Menjawab Langsung (SANGAT PENTING): +Kamu memiliki alat (tools) untuk mengelola server TeamTalk. Kamu sendiri yang harus memutuskan secara cerdas berdasarkan konteks kalimat apakah perlu memanggil tool atau menjawab langsung sebagai teks biasa: + +1. MENJAWAB LANGSUNG SEBAGAI TEKS BIASA (JANGAN PANGGIL TOOL): +- Pertanyaan pengetahuan umum, sains, teknologi, sekolah, sejarah, teka-teki, matematika, atau definisi (contoh: "yang nyetak uang dibayar pake apa?", "apa itu fotosintesis?", "kenapa langit biru?"). +- Pertanyaan atau diskusi tentang suatu konsep, meskipun menyebut kata seperti 'online', 'channel', atau 'kick' (contoh: "kok TeamTalk online sih, kenapa gak offline?", "apa bedanya mode online dan offline?", "kenapa di sepak bola ada tendangan bebas?"). +- Percakapan santai, curhat, sapaan, atau humor (contoh: "halo apa kabar bot?", "ceritain lelucon dong"). +-> Untuk SEMUA kasus ini, JAWAB LANGSUNG pertanyaannya secara ramah, cerdas, dan santai khas Gen Z! JANGAN panggil tool apapun, dan JANGAN PERNAH mengatakan "fungsi itu belum ada"! + +2. MEMANGGIL TOOL (HANYA UNTUK AKSI ATAU CEK DATA SERVER SAAT INI): +Panggil tool HANYA jika pengguna SECARA EKSPLISIT MEMERINTAHKAN kamu melakukan tindakan nyata pada server TeamTalk atau meminta data server saat ini: +- Perintah menendang user dari server -> panggil tool kick_user (bisa satu user atau beberapa user seperti 'kak budi dan tono') atau kick_channel_users (untuk semua user di satu channel) +- Perintah memblokir user -> panggil tool ban_user atau temp_ban_user +- Perintah membuka blokir -> panggil tool unban_user +- Perintah memindahkan user atau beberapa user ke channel lain -> panggil tool move_user (bisa sebut nama user, beberapa nama user dipisah koma/'dan', atau 'semua') +- Perintah memindahkan semua user dari satu channel ke channel lain (contoh: 'pindahin semua user di pelatihan komputer ke aula') -> panggil tool move_channel_users +- Perintah mengirim pesan pribadi (PM / Private Message) ke user -> panggil tool send_private_message +- Perintah membuat channel baru di server -> panggil tool create_channel +- Perintah menghapus channel dari server -> panggil tool delete_channel +- Perintah melihat daftar siapa saja pengguna yang ada di suatu channel -> panggil tool list_channel_users +- Perintah melihat file yang ada atau diunggah di suatu channel -> panggil tool list_channel_files +- Menanyakan info atau properti server (nama server, motd, kapasitas) -> panggil tool get_server_properties +- Perintah menjadikan user sebagai operator channel -> panggil tool set_channel_operator +- Perintah menyalakan/mematikan fitur moderasi atau filter kata -> panggil tool toggle_moderation_feature (PENTING: jika disuruh mematikan/nonaktifkan seperti 'matiin spam', 'matikan filter kata', set parameter enabled: false; jika disuruh menyalakan/mengaktifkan, set enabled: true) +- Perintah memaafkan atau mereset peringatan user -> panggil tool forgive_user +- Perintah menambah/menghapus kata terlarang -> panggil tool add_badword / delete_badword +- Menanyakan siapa saja pengguna yang sedang online saat ini di server -> panggil tool list_online_users +- Menanyakan daftar channel apa saja yang ada di server saat ini -> panggil tool list_channels +- Menanyakan di channel mana seorang user berada atau statusnya -> panggil tool find_user +- Menanyakan info teknis channel atau pemilik channel -> panggil tool get_channel_info / check_channel_owner +- Mengubah nama bot atau status bot -> panggil tool change_bot_nickname / change_bot_status +- Menyuruh bot bergabung atau keluar channel -> panggil tool join_channel / leave_channel +- Perintah mengirim pesan ke suatu channel atau channel ini (channel message, contoh: 'kirim channel message halo', 'kirim pesan ke channel ekskul: rapat dimulai') -> panggil tool send_channel_message (PENTING: jika user meminta channel message atau pesan ke channel, SELALU gunakan send_channel_message, JANGAN broadcast_message!). +- Perintah mengirim pengumuman/siaran ke SELURUH server (broadcast) -> panggil tool broadcast_message (HANYA jika user secara eksplisit meminta broadcast atau siaran ke seluruh server). +(Gunakan Informasi Server Terkini untuk memahami penanya seperti 'aku'/'saya' dan channel yang dimaksud). + +3. PERINTAH AKSI DI LUAR KEMAMPUAN: +HANYA jika pengguna SECARA EKSPLISIT MENYURUH bot melakukan aksi teknis yang TIDAK DIDUKUNG di server (contoh jika disuruh: "putar lagu dangdut dong", "pesenin makanan gofood dong", "restart server komputer dong"): +-> Baru berikan jawaban teks santai bahwa fitur atau fungsi tersebut belum tersedia di bot ini (contoh: "Wah kayaknya belum ada deh function buat itu di bot ini"). +JANGAN PERNAH terapkan aturan ini pada pertanyaan pengetahuan umum atau obrolan santai!""" # Characters screen readers read aloud from Markdown formatting _MARKDOWN = re.compile(r"[*#`_~]{1,3}") @@ -164,13 +168,13 @@ def clean_answer(text: str) -> str: }, { "name": "move_user", - "description": "Move a user to a different channel by nickname and channel name/path. Sensitive: requires admin privileges.", + "description": "Move one or more users to a different channel by nickname(s) (comma-separated or 'all') and destination channel name/path. Sensitive: requires admin privileges.", "parameters": { "type": "object", "properties": { "nickname": { "type": "string", - "description": "The nickname of the user to move.", + "description": "The nickname(s) of the user(s) to move (can be single name, comma-separated names, or 'all').", }, "channel": { "type": "string", @@ -180,6 +184,24 @@ def clean_answer(text: str) -> str: "required": ["nickname", "channel"], }, }, + { + "name": "move_channel_users", + "description": "Move all users currently inside a source channel to another destination channel using SDK doMoveUser. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "from_channel": { + "type": "string", + "description": "The source channel name or path where the users are currently located.", + }, + "to_channel": { + "type": "string", + "description": "The destination channel name or path.", + }, + }, + "required": ["from_channel", "to_channel"], + }, + }, { "name": "change_bot_status", "description": "Change the bot's status message. Sensitive: requires admin privileges.", @@ -196,17 +218,17 @@ def clean_answer(text: str) -> str: }, { "name": "toggle_moderation_feature", - "description": "Turn on or turn off moderation features such as word filter / badwords, spam/antispam, login, join, profile, pm, ai review, aichat, or all moderation features at once. Sensitive: requires admin privileges.", + "description": "Turn on or turn off moderation features such as word filter / badwords, spam protection / antispam, login, join, profile, pm, ai review, aichat, or all moderation features at once. Sensitive: requires admin privileges.", "parameters": { "type": "object", "properties": { "feature": { "type": "string", - "description": "The feature to toggle: 'badwords' (or 'word_filter'), 'spam' (or 'antispam'), 'login', 'join', 'profile', 'pm', 'ai', 'aichat', or 'all'/'moderation' for all moderation features.", + "description": "The feature to toggle: 'badwords' (or 'word_filter'), 'spam' (or 'antispam' / 'spam protection'), 'login', 'join', 'profile', 'pm', 'ai', 'aichat', or 'all'/'moderation'.", }, "enabled": { "type": "boolean", - "description": "True to enable/turn on (hidupkan/aktifkan), False to disable/turn off (matikan/nonaktifkan).", + "description": "CRITICAL: false if user asks to turn off / disable / matikan / nonaktifkan; true if user asks to turn on / enable / hidupkan / aktifkan.", }, }, "required": ["feature", "enabled"], @@ -431,6 +453,140 @@ def clean_answer(text: str) -> str: "required": ["message"], }, }, + { + "name": "send_channel_message", + "description": "Send a text message to a specific channel or the current channel (channel message) using SDK. Use this when a user asks to send a channel message, chat to a channel, or post in a channel, NOT broadcast_message. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The message content to send to the channel.", + }, + "channel": { + "type": "string", + "description": "Optional destination channel name or path. If omitted or 'sini'/'channel ini', defaults to the channel where the user asked.", + }, + }, + "required": ["message"], + }, + }, + { + "name": "send_private_message", + "description": "Send a private message (PM) from the bot to one or more users by nickname(s) using SDK. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "nickname": { + "type": "string", + "description": "The nickname(s) of the user(s) to receive the message.", + }, + "message": { + "type": "string", + "description": "The private message content to send.", + }, + }, + "required": ["nickname", "message"], + }, + }, + { + "name": "create_channel", + "description": "Create a new channel on the TeamTalk server using SDK doMakeChannel. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the new channel.", + }, + "parent_channel": { + "type": "string", + "description": "Optional parent channel name or path (defaults to root).", + }, + "topic": { + "type": "string", + "description": "Optional channel topic or description.", + }, + "password": { + "type": "string", + "description": "Optional password to protect the channel.", + }, + "max_users": { + "type": "integer", + "description": "Optional maximum number of users allowed in the channel.", + }, + }, + "required": ["name"], + }, + }, + { + "name": "delete_channel", + "description": "Delete a channel from the TeamTalk server by name or path using SDK doRemoveChannel. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "The channel name or path to delete.", + }, + }, + "required": ["channel"], + }, + }, + { + "name": "list_channel_users", + "description": "List all users currently inside a specific channel using SDK getChannelUsers.", + "parameters": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "The channel name or path to inspect.", + }, + }, + "required": ["channel"], + }, + }, + { + "name": "list_channel_files", + "description": "List uploaded files in a specific channel using SDK getChannelFiles.", + "parameters": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "The channel name or path.", + }, + }, + "required": ["channel"], + }, + }, + { + "name": "get_server_properties", + "description": "Get TeamTalk server properties such as server name, MOTD, and maximum user capacity using SDK getServerProperties.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "kick_channel_users", + "description": "Kick all users inside a specific channel from the server using SDK. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "The channel name or path whose users should be kicked.", + }, + "reason": { + "type": "string", + "description": "Optional reason for kicking the channel's users.", + }, + }, + "required": ["channel"], + }, + }, { "name": "get_bot_info", "description": "Get general information about the bot, version, and supported capabilities.", @@ -533,7 +689,7 @@ def _run(self) -> None: self._jobs.task_done() def _extract_tool_calls(self, result: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]: - """Extract tool calls from Workers AI result payload or generated text.""" + """Extract valid tool calls from Workers AI result payload or generated text.""" if not isinstance(result, dict): return None tool_calls = result.get("tool_calls") @@ -542,29 +698,35 @@ def _extract_tool_calls(self, result: Dict[str, Any]) -> Optional[List[Dict[str, if not tool_calls and isinstance(result.get("choices"), list) and result["choices"]: tool_calls = result["choices"][0].get("message", {}).get("tool_calls") + known_tools = {t["name"] for t in TOOLS} + aliases = { + "pm_user": "send_private_message", + "channel_message": "send_channel_message", + } + if tool_calls and isinstance(tool_calls, list): parsed_calls = [] for call in tool_calls: name = call.get("name") if not name and isinstance(call.get("function"), dict): name = call["function"].get("name") + if name in aliases: + name = aliases[name] args = call.get("arguments", {}) if isinstance(args, str): try: args = json.loads(args) except Exception: args = {} - if name: + if name and name in known_tools: parsed_calls.append({"name": name, "arguments": args, "id": call.get("id") or "call_1"}) if parsed_calls: return parsed_calls # Fallback: check text for tool call patterns raw_text = result.get("response") if isinstance(result, dict) else "" - known_tools = {t["name"] for t in TOOLS} if isinstance(raw_text, str) and ( - any(t in raw_text for t in known_tools) - or any(kw in raw_text for kw in ('"name"', '"function"', '"tool"', '"action"')) + any(t in raw_text for t in known_tools) or "pm_user" in raw_text or "channel_message" in raw_text ): decoder = json.JSONDecoder() idx = 0 @@ -581,13 +743,15 @@ def _extract_tool_calls(self, result: Dict[str, Any]) -> Optional[List[Dict[str, if extracted: return extracted t_name = obj.get("name") or obj.get("function") or obj.get("tool") or obj.get("action") + if t_name in aliases: + t_name = aliases[t_name] t_args = obj.get("arguments") or obj.get("parameters") or {} if isinstance(t_args, str): try: t_args = json.loads(t_args) except Exception: t_args = {} - if t_name: + if t_name and t_name in known_tools: return [{"name": t_name, "arguments": t_args, "id": "call_1"}] idx = start_brace + max(1, end_pos) except Exception: @@ -595,13 +759,16 @@ def _extract_tool_calls(self, result: Dict[str, Any]) -> Optional[List[Dict[str, return None def answer(self, question: str, history: List[Tuple[str, str]], context: Any = None) -> str: - """Ask the model and return its cleaned answer; handles function calls.""" + """Ask the model and return its cleaned answer; AI decides whether to call tools.""" system_content = SYSTEM_PROMPT if isinstance(context, dict): extra = [] req_nick = context.get("nickname") or "Pengguna" - chan_name = context.get("channel_name") or f"channel {context.get('channel')}" - extra.append(f"Penanya: {req_nick} (berada di channel: {chan_name}).") + is_pm = bool(context.get("is_pm")) + if is_pm: + extra.append(f"Penanya: {req_nick} (mengirim pertanyaan via Private Message / PM, sedang berada di channel: {chan_name}).") + else: + extra.append(f"Penanya: {req_nick} (berada di channel: {chan_name}).") if context.get("online_users"): extra.append(f"Pengguna online: {', '.join(context['online_users'][:30])}.") if context.get("channels"): @@ -615,12 +782,14 @@ def answer(self, question: str, history: List[Tuple[str, str]], context: Any = N messages.append({"role": "assistant", "content": earlier_answer}) messages.append({"role": "user", "content": question}) - body = { + body: Dict[str, Any] = { "messages": messages, "tools": TOOLS, + "tool_choice": "auto", "max_tokens": self._max_tokens, "temperature": 0.4, } + try: payload = call_workers_ai( self._api_url, @@ -633,6 +802,7 @@ def answer(self, question: str, history: List[Tuple[str, str]], context: Any = N except Exception: # Fallback without tools if model/endpoint dislikes tools parameter body.pop("tools", None) + body.pop("tool_choice", None) payload = call_workers_ai( self._api_url, self._account_id, @@ -741,4 +911,59 @@ def answer(self, question: str, history: List[Tuple[str, str]], context: Any = N text = clean_answer(result.get("response") if isinstance(result, dict) else "") if not text: raise ValueError("empty answer from the model") + + # Safety check: if model mistakenly returned a function refusal on a general knowledge question + refusal_phrases = ( + "belum ada function", + "fungsi itu belum ada", + "fitur itu belum ada", + "fitur itu belum tersedia", + "belum ada fitur", + "fungsi tersebut belum ada", + "belum tersedia di bot ini", + ) + text_lower = text.lower() + if any(phrase in text_lower for phrase in refusal_phrases): + explicit_unsupported_commands = ( + "putar lagu", "setel lagu", "putar musik", "setel musik", "play lagu", "play musik", + "pesan makanan", "pesen makanan", "order makanan", "gofood", "grabfood", + "restart", "reboot", "shutdown", + ) + is_unsupported_command = any(cmd in question.lower() for cmd in explicit_unsupported_commands) + if not is_unsupported_command: + logger.warning("Model answered with function refusal for general query: %s. Retrying pure QA.", question) + try: + qa_messages = [ + { + "role": "system", + "content": ( + "Kamu adalah asisten AI ramah dan cerdas khas Gen Z untuk siswa tunanetra di TeamTalk. " + "Gunakan bahasa Indonesia santai ('aku' dan 'kamu'). " + "Jawab pertanyaan pengguna berikut ini secara langsung, pintar, dan informatif. " + "Plain text ONLY: dilarang menggunakan format Markdown (tanpa bintang, pagar, strip) dan dilarang menggunakan emoji. " + "Maksimal 4 kalimat. Ini adalah pertanyaan pengetahuan umum/obrolan biasa, BUKAN perintah fungsi server." + ), + }, + {"role": "user", "content": question}, + ] + qa_payload = call_workers_ai( + self._api_url, + self._account_id, + self._api_token, + self.model, + { + "messages": qa_messages, + "max_tokens": self._max_tokens, + "temperature": 0.4, + }, + self._timeout, + ) + if qa_payload.get("success", True): + qa_res = qa_payload.get("result") or {} + qa_ans = clean_answer(qa_res.get("response") if isinstance(qa_res, dict) else "") + if qa_ans: + return qa_ans + except Exception as exc: + logger.warning("Pure QA retry failed: %s", exc) + return text diff --git a/docs/ai.md b/docs/ai.md index 389f3c5..84da1f7 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -75,29 +75,35 @@ messages are included, so mention the check in the server rules. Switch it off again any time with `/abt ai off`; ambiguous words are then counted immediately, as before. -## Ask the AI in a channel (@ai) +## Ask the AI in a channel or private message (@ai / /ai) -Anyone can start a channel message with `@ai` followed by a question: +Anyone can start a channel message or send a private message (PM) to the bot with `@ai` or `/ai` followed by a question: ``` @ai apa itu fotosintesis? ``` -The bot answers **in that channel** (as an admin it does not need to join -it), so everyone there hears the answer: +- When asked **in a channel**, the bot answers in that channel (as an admin it does not need to join it) so everyone there hears the answer: ``` [AI] untuk Siti: Fotosintesis adalah ... AI bisa salah. Periksa kembali info penting. ``` +- When asked **via private message (PM)**, the bot answers directly in PM without the nickname prefix: + +``` +[AI]: Fotosintesis adalah ... +AI bisa salah. Periksa kembali info penting. +``` + - Answers are short plain text in the language of the question, with no Markdown or emoji, because screen readers read symbols aloud. - The AI remembers the last `AI_CHAT_HISTORY` (default 3) questions and - answers of that channel from the last 10 minutes, so follow-ups such as - `@ai jelasin lebih simpel` work. Nicknames are not sent to the AI. + answers of that channel (or privately per user for PMs) from the last 10 minutes, so follow-ups such as + `@ai jelasin lebih simpel` work seamlessly. Nicknames are not sent to the AI. - `@ai` must be followed by a space, `:` or `,`: `@aisyah halo` is not a - question. Private messages starting with `@ai` are not answered. + question. In PM, `/ai ` is also supported. - Limits: one question per person every `AI_CHAT_COOLDOWN_SEC` (default 20) seconds and `AI_CHAT_DAILY_LIMIT` (default 200) questions per day for the whole server; people who hit a limit are told privately. @@ -122,7 +128,8 @@ Users can request actions in natural conversation without remembering manual sla The AI automatically invokes tools executed safely on the bot's main thread: - `toggle_moderation_feature`: enable or disable moderation features (`word_filter` / `badwords`, `spam`, `login`, `join`, `profile`, `pm`, `ai`, `aichat`, or `all`/`moderation` for all features at once) (sensitive, requires admin). - `get_moderation_status`: check current ON/OFF status of all moderation features (sensitive, requires admin). -- `kick_user`: kick a user by nickname (sensitive, requires admin). +- `kick_user`: kick one or more users by nickname (e.g. `@ai kick budi dan tono`) (sensitive, requires admin). +- `kick_channel_users`: kick all users inside a specific channel from the server (sensitive, requires admin). - `ban_user`: permanently ban a user by nickname (sensitive, requires admin). - `temp_ban_user`: temporarily ban a user for X minutes (sensitive, requires admin). - `unban_user`: unban a user by username or IP (sensitive, requires admin). @@ -131,7 +138,14 @@ The AI automatically invokes tools executed safely on the bot's main thread: - `delete_badword`: remove word(s) from the word filter (sensitive, requires admin). - `list_badwords`: list or search words in the word filter (sensitive, requires admin). - `list_bans`: view active temporary bans (sensitive, requires admin). -- `move_user`: move a user to another channel (sensitive, requires admin). +- `move_user`: move one or more users to another channel (supports single user, comma-separated names, "dan", or "semua") (sensitive, requires admin). +- `move_channel_users`: move all users from a source channel to another channel (e.g. `@ai pindahin semua user yang ada di pelatihan komputer ke aula dong`) (sensitive, requires admin). +- `send_private_message` (alias: `pm_user`): send a private message from the bot to one or more users (sensitive, requires admin). +- `create_channel`: create a new channel with audio settings and defaults using SDK `doMakeChannel` (sensitive, requires admin). +- `delete_channel`: remove a channel using SDK `doRemoveChannel` (sensitive, requires admin). +- `list_channel_users`: list users inside a specific channel using SDK `getChannelUsers` (available to everyone). +- `list_channel_files`: list uploaded files in a channel using SDK `getChannelFiles` (available to everyone). +- `get_server_properties`: view server name, MOTD, and maximum user capacity from SDK `getServerProperties` (available to everyone). - `change_bot_status`: update the bot's status message (sensitive, requires admin). - `list_online_users`: list online users and their channels (available to everyone). - `list_channels`: list server channels (available to everyone). @@ -142,13 +156,15 @@ The AI automatically invokes tools executed safely on the bot's main thread: - `leave_channel`: make the bot leave its current channel and return to root using SDK (sensitive, requires admin). - `set_channel_operator`: grant or revoke Channel Operator (ChanOp) status for a user using SDK `doChannelOpEx` (sensitive, requires admin). - `get_channel_info`: view technical channel settings (topic, max users, password protection) from SDK `getChannel` (available to everyone). -- `get_user_info`: view user profile details from SDK `getUser` (available to everyone; sensitive fields like IP are admin only). -- `broadcast_message`: broadcast an announcement message across the server using SDK `TextMsgType.MSGTYPE_BROADCAST` (sensitive, requires admin). +- `send_channel_message` (alias: `channel_message`): send a text message to a specific channel or the current channel using SDK `TextMsgType.MSGTYPE_CHANNEL` (sensitive, requires admin). +- `broadcast_message`: broadcast an announcement message across the server using SDK `TextMsgType.MSGTYPE_BROADCAST` (sensitive, requires admin; strictly reserved for server-wide broadcasts, never used for channel messages). - `get_bot_info`: get bot version and supported features (available to everyone). -**Admin permissions**: Sensitive actions strictly check the requester's `uUserType & UserType.USERTYPE_ADMIN`. If a non-admin requests a restricted action (like kicking, moving, or disabling moderation), permission is denied and the AI replies in its friendly Gen Z persona (for example: *"Woi kamu bukan admin bro, gak boleh aneh-aneh ya"*). +**Admin permissions**: Sensitive actions strictly check the requester's `uUserType & UserType.USERTYPE_ADMIN`. If a non-admin requests a restricted action (like kicking, moving, private messaging, creating/deleting channels, or disabling moderation), permission is denied and the AI replies in its friendly Gen Z persona (for example: *"Woi kamu bukan admin bro, gak boleh aneh-aneh ya"*). + +**General questions & casual chat**: General knowledge questions (science, history, trivia, riddles, school subjects, daily conversation) are always answered directly, naturally, and supportively. The bot never checks for functions or says functions are missing for general conversation. -**Unrecognized or unsupported commands**: If a user asks the AI to perform a task or action that does not have a corresponding function in the bot (for example: playing songs, ordering food, restarting servers), the AI does not hallucinate or guess; it politely informs the user in its Gen Z persona that the function is not yet available (for example: *"Wah kayaknya belum ada deh function buat itu di bot ini"*). +**Unrecognized or unsupported commands**: ONLY if a user explicitly gives a command or asks the bot to execute a server/bot action that does not exist (for example: commanding the bot to play music, order food, or restart servers), the AI politely informs the user in its Gen Z persona that the function is not yet available (for example: *"Wah kayaknya belum ada deh function buat itu di bot ini"*). **Live Server Context & Fuzzy Lookups**: - **Server snapshot injection**: When answering `@ai`, the bot injects real-time context into the AI's prompt: requester nickname, requester's current channel, online users and their channels, and the server channel list. diff --git a/tt_client.py b/tt_client.py index 3cf6b90..8871f8e 100644 --- a/tt_client.py +++ b/tt_client.py @@ -46,6 +46,7 @@ AudioCodec, OpusCodec, Codec, + OPUS_APPLICATION_VOIP, buildTextMessage, BanType, BannedUser, @@ -952,6 +953,7 @@ def onCmdUserTextMessage(self, textmessage: TextMessage): "channel": int(textmessage.nChannelID), "question": question, "held": False, + "is_pm": False, } # Filter bad words in channel / broadcast messages if configured if textmessage.nMsgType != TextMsgType.MSGTYPE_USER: @@ -983,7 +985,12 @@ def onCmdUserTextMessage(self, textmessage: TextMessage): self._handle_abuse_message(from_uid, self._get_user_ip(from_uid)) ask = self._current_ai_question if ask and not ask["held"]: - self._handle_ai_question(ask["userid"], ask["channel"], ask["question"]) + self._handle_ai_question( + ask["userid"], + ask["channel"], + ask["question"], + is_pm=bool(ask.get("is_pm", False)), + ) return from_user = textmessage.nFromUserID content = message_text.strip() @@ -1040,6 +1047,41 @@ def onCmdUserTextMessage(self, textmessage: TextMessage): if from_user in self._abuse_menus: if abuse_menu.handle_response(self, from_user, content): return + # Check if this PM is an @ai question (e.g. "@ai apa itu fotosintesis?" or "/ai ...") + ai_pm_question = None + if self._ai_chat_active() and from_user != (self.getMyUserID() or 0): + if content.lower().startswith("/ai ") or content.lower() == "/ai": + ai_pm_question = content[3:].lstrip(" :,\t\n").strip() + else: + ai_pm_question = self._ai_chat_question(content) + + if ai_pm_question is not None and self._ai_chat_active() and from_user != (self.getMyUserID() or 0): + u = safe_call(self.getUser, from_user) + user_chan = u.nChannelID if (u is not None and hasattr(u, "nChannelID")) else 1 + self._current_ai_question = { + "userid": from_user, + "channel": user_chan, + "question": ai_pm_question, + "held": False, + "is_pm": True, + } + if ( + self._bw_enabled() + and ("PRIVATE" in self._bw_types()) + and not self._is_badword_admin_command(from_user, content) + ): + moderation_utils.check_text_badwords(self, textmessage, message_text) + + if self._spam_enabled(): + spam_types = self._spam_types() + if "PRIVATE" in spam_types: + self._handle_abuse_message(from_uid, self._get_user_ip(from_uid)) + + ask = self._current_ai_question + if ask and not ask["held"]: + self._handle_ai_question(ask["userid"], ask["channel"], ask["question"], is_pm=True) + return + # Routing heuristic for private messages. # # The TeamTalk SDK always delivers textmessage.nToUserID = 0 for received @@ -2539,7 +2581,12 @@ def _submit_ai_review(self, content: str, entries: List[str], context: dict, cur def _answer_held_question(self, ask: Optional[dict]): """Answer an "@ai" question whose badword was not counted after all.""" if ask and self._ai_chat_active(): - self._handle_ai_question(ask["userid"], ask["channel"], ask["question"]) + self._handle_ai_question( + ask["userid"], + ask["channel"], + ask["question"], + is_pm=bool(ask.get("is_pm", False)), + ) # ============ "@ai " in a channel ============ _AI_CHAT_HISTORY_SEC = 600.0 # earlier questions older than this are forgotten @@ -2583,7 +2630,7 @@ def _is_ai_chat_blocked_user(self, userid: int) -> bool: pass return False - def _handle_ai_question(self, userid: int, channel_id: int, question: str): + def _handle_ai_question(self, userid: int, channel_id: int, question: str, is_pm: bool = False): if self._is_ai_chat_blocked_user(userid): msg = getattr( config, @@ -2599,9 +2646,11 @@ def _handle_ai_question(self, userid: int, channel_id: int, question: str): return if not question: prefix = str(getattr(config, "AI_CHAT_PREFIX", "@ai") or "@ai").strip() - self._send_channel_text( - channel_id, f"Tulis pertanyaan setelah {prefix}, contoh: {prefix} apa itu fotosintesis?" - ) + prompt_msg = f"Tulis pertanyaan setelah {prefix}, contoh: {prefix} apa itu fotosintesis?" + if is_pm: + safe_call(self.send_pm, userid, prompt_msg) + else: + self._send_channel_text(channel_id, prompt_msg) return now = self._now() cooldown = float(getattr(config, "AI_CHAT_COOLDOWN_SEC", 20) or 0) @@ -2620,9 +2669,11 @@ def _handle_ai_question(self, userid: int, channel_id: int, question: str): if self._ai_chat_count >= int(getattr(config, "AI_CHAT_DAILY_LIMIT", 200) or 0): safe_call(self.send_pm, userid, "Batas pertanyaan ke AI untuk hari ini sudah habis.") return + + hist_key = f"pm_{userid}" if is_pm else channel_id history = [ (q, a) - for ts, q, a in self._ai_chat_history.get(channel_id, ()) + for ts, q, a in self._ai_chat_history.get(hist_key, ()) if now - ts <= self._AI_CHAT_HISTORY_SEC ] user = safe_call(self.getUser, userid) @@ -2650,6 +2701,7 @@ def _handle_ai_question(self, userid: int, channel_id: int, question: str): "nickname": nickname, "question": question, "is_admin": self._is_admin(userid), + "is_pm": is_pm, "online_users": online_users[:40], "channels": channels_list[:40], } @@ -2659,17 +2711,20 @@ def _handle_ai_question(self, userid: int, channel_id: int, question: str): self._ai_chat_last[userid] = now self._ai_chat_count += 1 logger.info( - "AI chat question (user=%s, channel=%s, %s chars, %s earlier turns)", + "AI chat question (user=%s, channel=%s, is_pm=%s, %s chars, %s earlier turns)", userid, channel_id, + is_pm, len(question), len(history), ) def _process_ai_chat_results(self): - """Post finished AI answers in the channel they were asked in.""" + """Post finished AI answers in the channel or via PM.""" for context, answer in self._ai_chat.drain(): channel_id = context["channel"] + userid = context["userid"] + is_pm = bool(context.get("is_pm", False)) if answer is None: text = "Maaf, AI tidak bisa menjawab sekarang. Coba lagi nanti." else: @@ -2677,18 +2732,22 @@ def _process_ai_chat_results(self): # "anjing" in an answer about dogs are fine) ambiguous = self._ambiguous_words() if any(e not in ambiguous for e in self._badwords.matching_entries(answer)): - logger.warning("AI chat answer contained a badword; replaced (channel=%s)", channel_id) + logger.warning("AI chat answer contained a badword; replaced (channel=%s, is_pm=%s)", channel_id, is_pm) answer = self._AI_CHAT_REFUSAL size = max(0, int(getattr(config, "AI_CHAT_HISTORY", 3) or 0)) - history = self._ai_chat_history.setdefault(channel_id, deque(maxlen=max(1, size))) + hist_key = f"pm_{userid}" if is_pm else channel_id + history = self._ai_chat_history.setdefault(hist_key, deque(maxlen=max(1, size))) if size: history.append((self._now(), context["question"], answer)) - who = f" untuk {context['nickname']}" if context.get("nickname") else "" + who = f" untuk {context['nickname']}" if (context.get("nickname") and not is_pm) else "" disclaimer = str(getattr(config, "AI_CHAT_DISCLAIMER", "") or "").strip() text = f"[AI]{who}: {answer}" if disclaimer: text += f"\n{disclaimer}" - self._send_channel_text(channel_id, text) + if is_pm: + safe_call(self.send_pm, userid, text) + else: + self._send_channel_text(channel_id, text) def _send_channel_text(self, channel_id: int, text: str): """Post a channel message; as an admin the bot need not be in the channel.""" @@ -2763,6 +2822,32 @@ def _find_users_by_nickname_fuzzy(self, name: str, context: Any = None) -> List[ return partial return [] + def _find_multiple_users(self, raw_input: str, context: Any = None) -> List[User]: + clean = str(raw_input or "").strip() + if not clean: + return [] + + myid = self.getMyUserID() or 0 + if clean.lower() in ("all", "semua", "semua user", "semua orang"): + cid = context.get("channel") if isinstance(context, dict) else None + if cid: + return [u for u in self.getServerUsers() if u.nChannelID == int(cid) and u.nUserID != myid] + return [u for u in self.getServerUsers() if u.nUserID != myid] + + parts = re.split(r",|\s+dan\s+|\s+and\s+", clean, flags=re.IGNORECASE) + found: List[User] = [] + seen_ids = set() + for p in parts: + p_str = p.strip() + if not p_str: + continue + matched = self._find_users_by_nickname_fuzzy(p_str, context) + for u in matched: + if u.nUserID not in seen_ids: + seen_ids.add(u.nUserID) + found.append(u) + return found + def _find_channel_id(self, target: str, context: Any = None) -> Optional[int]: target_str = str(target or "").strip() target_lower = target_str.lower().strip("/") @@ -2837,7 +2922,7 @@ def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dic } nickname = str(tool_args.get("nickname") or "").strip() reason = str(tool_args.get("reason") or "").strip() - targets = self._find_users_by_nickname_fuzzy(nickname, context) + targets = self._find_multiple_users(nickname, context) if not targets: return { "status": "error", @@ -2878,7 +2963,7 @@ def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dic } nickname = str(tool_args.get("nickname") or "").strip() reason = str(tool_args.get("reason") or "").strip() - targets = self._find_users_by_nickname_fuzzy(nickname, context) + targets = self._find_multiple_users(nickname, context) if not targets: return { "status": "error", @@ -2924,7 +3009,7 @@ def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dic } nickname = str(tool_args.get("nickname") or "").strip() channel_target = str(tool_args.get("channel") or "").strip() - targets = self._find_users_by_nickname_fuzzy(nickname, context) + targets = self._find_multiple_users(nickname, context) if not targets: return { "status": "error", @@ -2948,6 +3033,52 @@ def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dic "message": f"User {', '.join(moved)} berhasil dipindahkan ke channel {chan_name}.", } + elif tool_name == "move_channel_users": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh mindahin orang.", + } + from_channel = str(tool_args.get("from_channel") or "").strip() + to_channel = str(tool_args.get("to_channel") or "").strip() + from_cid = self._find_channel_id(from_channel, context) + if not from_cid: + return { + "status": "error", + "error": "CHANNEL_NOT_FOUND", + "message": f"Channel asal '{from_channel}' tidak ditemukan di server.", + } + to_cid = self._find_channel_id(to_channel, context) + if not to_cid: + return { + "status": "error", + "error": "CHANNEL_NOT_FOUND", + "message": f"Channel tujuan '{to_channel}' tidak ditemukan di server.", + } + if from_cid == to_cid: + return { + "status": "error", + "message": "Channel asal dan channel tujuan tidak boleh sama.", + } + myid = self.getMyUserID() or 0 + targets = [u for u in self.getServerUsers() if u.nChannelID == from_cid and u.nUserID != myid] + from_path = self.getChannelPath(from_cid) or from_channel + to_path = self.getChannelPath(to_cid) or to_channel + if not targets: + return { + "status": "success", + "message": f"Tidak ada pengguna di channel {from_path} untuk dipindahkan ke {to_path}.", + } + moved = [] + for u in targets: + self.doMoveUser(u.nUserID, to_cid) + moved.append(from_tt_char(u.szNickname)) + return { + "status": "success", + "message": f"Berhasil memindahkan {len(moved)} user ({', '.join(moved)}) dari channel {from_path} ke {to_path}.", + } + elif tool_name == "list_online_users": users = self.getServerUsers() online = [] @@ -3091,9 +3222,44 @@ def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dic "message": "Kamu bukan admin, jadi gak boleh ngatur fitur moderasi.", } feat_raw = str(tool_args.get("feature") or "").strip().lower() - enabled = bool(tool_args.get("enabled", True)) + + # Determine enabled status robustly: parse strings ("false", "off"), numbers, and user question context + q_text = str(context.get("question") or "") if isinstance(context, dict) else "" + q_lower = q_text.lower() + wants_off = any(w in q_lower for w in ("matiin", "matikan", "nonaktifkan", "turn off", "disable", "non aktif", "non-aktif", "matikanlah", " off")) + wants_on = any(w in q_lower for w in ("hidupin", "hidupkan", "aktifkan", "nyalain", "nyalakan", "turn on", "enable", " on ", "hidupkanlah")) + + raw_val = tool_args.get("enabled") + if raw_val is None: + for k in ("action", "state", "status", "mode", "toggle"): + if k in tool_args: + raw_val = tool_args[k] + break + + if wants_off and not wants_on: + enabled = False + elif wants_on and not wants_off: + enabled = True + elif raw_val is not None: + if isinstance(raw_val, bool): + enabled = raw_val + elif isinstance(raw_val, (int, float)): + enabled = (raw_val != 0) + elif isinstance(raw_val, str): + v = raw_val.strip().lower() + if v in ("false", "0", "off", "mati", "matikan", "matiin", "nonaktif", "nonaktifkan", "disable", "disabled", "no", "tidak"): + enabled = False + elif v in ("true", "1", "on", "hidup", "hidupkan", "hidupin", "aktif", "aktifkan", "nyala", "nyalakan", "enable", "enabled", "yes", "ya"): + enabled = True + else: + enabled = True + else: + enabled = bool(raw_val) + else: + enabled = True + action_str = "diaktifkan" if enabled else "dinonaktifkan" - if feat_raw in ("all", "moderation", "semua", "semua moderasi", "semua fitur"): + if feat_raw in ("all", "moderation", "moderasi", "semua", "semua moderasi", "semua fitur"): target_features = ["login", "join", "spam", "badwords", "profile", "pm"] for f in target_features: self._set_feature(f, enabled) @@ -3107,20 +3273,31 @@ def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dic "word filter": "badwords", "filter kata": "badwords", "filter_kata": "badwords", + "filter": "badwords", "badword": "badwords", "badwords": "badwords", + "kata kasar": "badwords", "antispam": "spam", "anti spam": "spam", "anti_spam": "spam", "spam": "spam", + "spam protection": "spam", + "proteksi spam": "spam", + "perlindungan spam": "spam", "login": "login", "join": "join", "profile": "profile", + "profil": "profile", "pm": "pm", + "private message": "pm", + "pesan pribadi": "pm", "ai": "ai", + "ai review": "ai", + "review ai": "ai", "aichat": "aichat", "ai_chat": "aichat", "chat_ai": "aichat", + "chat ai": "aichat", } target_feat = mapping.get(feat_raw, feat_raw) known_features = {name for name, _desc, _key in self._FEATURES} @@ -3430,7 +3607,19 @@ def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dic } nickname = str(tool_args.get("nickname") or "").strip() channel_target = str(tool_args.get("channel") or "").strip() - is_op = bool(tool_args.get("is_operator", True)) + raw_op = tool_args.get("is_operator") + q_text = str(context.get("question") or "").lower() if isinstance(context, dict) else "" + if any(w in q_text for w in ("cabut", "hapus", "revoke", "bukan operator", "jangan jadi operator")): + is_op = False + elif any(w in q_text for w in ("jadiin", "jadikan", "beri", "grant", "tambah")): + is_op = True + elif raw_op is not None: + if isinstance(raw_op, str) and raw_op.strip().lower() in ("false", "0", "off", "cabut", "revoke"): + is_op = False + else: + is_op = bool(raw_op) + else: + is_op = True if not nickname: nickname = "aku" targets = self._find_users_by_nickname_fuzzy(nickname, context) @@ -3513,6 +3702,29 @@ def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dic "message": f"Data pengguna: {', '.join(details)}.", } + elif tool_name in ("send_channel_message", "channel_message"): + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh nyuruh bot kirim pesan channel.", + } + msg = str(tool_args.get("message") or "").strip() + if not msg: + return {"status": "error", "message": "Pesan channel tidak boleh kosong."} + channel_target = str(tool_args.get("channel") or "").strip() + cid = self._find_channel_id(channel_target, context) if channel_target else None + if not cid and isinstance(context, dict) and context.get("channel"): + cid = int(context["channel"]) + if not cid: + cid = self.getRootChannelID() or 1 + self._send_channel_text(cid, msg) + cpath = self.getChannelPath(cid) or f"channel {cid}" + return { + "status": "success", + "message": f"Pesan berhasil dikirim ke {cpath}: '{msg}'.", + } + elif tool_name == "broadcast_message": if not is_admin: return { @@ -3523,6 +3735,33 @@ def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dic msg = str(tool_args.get("message") or "").strip() if not msg: return {"status": "error", "message": "Pesan broadcast tidak boleh kosong."} + + # Safety redirect: if user actually requested a channel message or specified a channel, + # send to channel instead of broadcasting to the entire server + q_text = str(context.get("question") or "").lower() if isinstance(context, dict) else "" + channel_in_args = str(tool_args.get("channel") or "").strip() + is_channel_msg_intent = ( + bool(channel_in_args) + or "channel message" in q_text + or "pesan channel" in q_text + or "pesan ke channel" in q_text + or "chat ke channel" in q_text + or "chat channel" in q_text + or ("ke channel" in q_text and not any(b in q_text for b in ("broadcast", "siaran", "seluruh server", "semua channel"))) + ) + if is_channel_msg_intent: + cid = self._find_channel_id(channel_in_args, context) if channel_in_args else None + if not cid and isinstance(context, dict) and context.get("channel"): + cid = int(context["channel"]) + if not cid: + cid = self.getRootChannelID() or 1 + self._send_channel_text(cid, msg) + cpath = self.getChannelPath(cid) or f"channel {cid}" + return { + "status": "success", + "message": f"Pesan berhasil dikirim ke {cpath}: '{msg}'.", + } + my_id = self.getMyUserID() or 0 parts = buildTextMessage(content=msg, nMsgType=TextMsgType.MSGTYPE_BROADCAST, nFromUserID=my_id) for part in parts: @@ -3532,6 +3771,282 @@ def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dic "message": f"Pesan siaran berhasil dikirim ke seluruh server: '{msg}'.", } + elif tool_name in ("send_private_message", "pm_user"): + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh nyuruh bot kirim pesan pribadi.", + } + nickname = str(tool_args.get("nickname") or "").strip() + msg = str(tool_args.get("message") or "").strip() + if not msg: + return {"status": "error", "message": "Pesan pribadi tidak boleh kosong."} + targets = self._find_multiple_users(nickname, context) + if not targets: + return { + "status": "error", + "error": "USER_NOT_FOUND", + "message": f"User '{nickname}' tidak ditemukan di server.", + } + sent_to = [] + for u in targets: + safe_call(self.send_pm, u.nUserID, msg) + sent_to.append(from_tt_char(u.szNickname)) + return { + "status": "success", + "message": f"Pesan pribadi berhasil dikirim ke {', '.join(sent_to)}: '{msg}'.", + } + + elif tool_name == "create_channel": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh buat channel baru.", + } + name = str(tool_args.get("name") or "").strip() + if not name: + return {"status": "error", "message": "Nama channel tidak boleh kosong."} + parent_channel = str(tool_args.get("parent_channel") or "").strip() + topic = str(tool_args.get("topic") or "").strip() + password = str(tool_args.get("password") or "").strip() + try: + max_users = int(tool_args.get("max_users") or 0) + except Exception: + max_users = 0 + + parent_id = 0 + if parent_channel: + parent_id = self._find_channel_id(parent_channel, context) or 0 + if parent_id <= 0: + parent_id = self.getRootChannelID() if hasattr(self, "getRootChannelID") else 1 + if not parent_id or parent_id <= 0: + parent_id = 1 + + chan = Channel() + chan.nParentID = parent_id + assign_tt_char_array((chan, "szName"), name) + assign_tt_char_array((chan, "szTopic"), topic) + assign_tt_char_array((chan, "szPassword"), password) + chan.bPassword = bool(password) + assign_tt_char_array((chan, "szOpPassword"), "") + + try: + channel_cfg = channel_wizard._channel_defaults() + audio_cfg = channel_wizard._audio_defaults() + except Exception: + channel_cfg = {} + audio_cfg = {} + + chan.nMaxUsers = max_users if max_users > 0 else int(channel_cfg.get("max_users", 50)) + chan.nDiskQuota = int(channel_cfg.get("disk_quota_mb", 100)) * 1024 * 1024 + chan.nUserData = 0 + chan.uChannelType = ChannelType.CHANNEL_PERMANENT + + ac = AudioCodec() + ac.nCodec = Codec.OPUS_CODEC + oc = OpusCodec() + oc.nApplication = OPUS_APPLICATION_VOIP + oc.nSampleRate = int(audio_cfg.get("sample_rate", 48000)) + oc.nChannels = 1 + oc.nBitRate = int(audio_cfg.get("bitrate_kbps", 64)) * 1000 + oc.bVBR = True + oc.bDTX = False + oc.bFEC = False + oc.bVBRConstraint = False + oc.nTxIntervalMSec = 20 + oc.nFrameSizeMSec = 20 + oc.nComplexity = 10 + ac.u.opus = oc + chan.audiocodec = ac + chan.audiocfg.bEnableAGC = False + chan.audiocfg.nGainLevel = 0 + + cmdid = self.doMakeChannel(chan) + if cmdid <= 0: + return { + "status": "error", + "message": f"Gagal mengirim permintaan pembuatan channel '{name}' ke server.", + } + parent_path = self.getChannelPath(parent_id) or f"channel {parent_id}" + return { + "status": "success", + "message": f"Channel '{name}' berhasil dibuat di {parent_path}.", + } + + elif tool_name == "delete_channel": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh hapus channel.", + } + channel_target = str(tool_args.get("channel") or "").strip() + cid = self._find_channel_id(channel_target, context) + if not cid: + return { + "status": "error", + "error": "CHANNEL_NOT_FOUND", + "message": f"Channel '{channel_target}' tidak ditemukan di server.", + } + root_id = self.getRootChannelID() if hasattr(self, "getRootChannelID") else 1 + if cid == root_id or cid == 1: + return { + "status": "error", + "message": "Channel root tidak boleh dihapus.", + } + cpath = self.getChannelPath(cid) or channel_target + cmdid = self.doRemoveChannel(cid) + if cmdid <= 0: + return { + "status": "error", + "message": f"Gagal mengirim perintah hapus channel '{cpath}' ke server.", + } + return { + "status": "success", + "message": f"Channel {cpath} berhasil dihapus dari server.", + } + + elif tool_name == "list_channel_users": + channel_target = str(tool_args.get("channel") or "").strip() + cid = self._find_channel_id(channel_target, context) + if not cid: + return { + "status": "error", + "error": "CHANNEL_NOT_FOUND", + "message": f"Channel '{channel_target}' tidak ditemukan di server.", + } + cpath = self.getChannelPath(cid) or channel_target + try: + chan_users = self.getChannelUsers(cid) or [] + except Exception: + chan_users = [] + if not chan_users: + chan_users = [u for u in self.getServerUsers() if u.nChannelID == cid] + nicks = [] + for u in chan_users: + nick = from_tt_char(u.szNickname) + if nick: + nicks.append(nick) + if not nicks: + return { + "status": "success", + "users": [], + "total": 0, + "message": f"Tidak ada pengguna di channel {cpath} saat ini.", + } + return { + "status": "success", + "users": nicks, + "total": len(nicks), + "message": f"Ada {len(nicks)} pengguna di channel {cpath}: {', '.join(nicks)}.", + } + + elif tool_name == "list_channel_files": + channel_target = str(tool_args.get("channel") or "").strip() + cid = self._find_channel_id(channel_target, context) + if not cid: + return { + "status": "error", + "error": "CHANNEL_NOT_FOUND", + "message": f"Channel '{channel_target}' tidak ditemukan di server.", + } + cpath = self.getChannelPath(cid) or channel_target + try: + files = self.getChannelFiles(cid) or [] + except Exception: + files = [] + file_names = [] + for f in files: + name = from_tt_char(f.szFileName) + size_kb = (f.nFileSize or 0) // 1024 + if name: + file_names.append(f"{name} ({size_kb} KB)") + if not file_names: + return { + "status": "success", + "files": [], + "total": 0, + "message": f"Tidak ada file yang diunggah di channel {cpath}.", + } + return { + "status": "success", + "files": file_names, + "total": len(file_names), + "message": f"Ditemukan {len(file_names)} file di channel {cpath}: {', '.join(file_names)}.", + } + + elif tool_name == "get_server_properties": + try: + props = self.getServerProperties() + except Exception: + props = None + if not props: + return { + "status": "error", + "message": "Gagal mengambil properti server saat ini.", + } + sname = from_tt_char(props.szServerName) if hasattr(props, "szServerName") else "TeamTalk Server" + motd = from_tt_char(props.szMOTD).strip() if hasattr(props, "szMOTD") else "" + max_u = props.nMaxUsers if hasattr(props, "nMaxUsers") else 0 + ver = from_tt_char(props.szServerVersion) if hasattr(props, "szServerVersion") else "" + parts = [f"Nama server: {sname}"] + if motd: + parts.append(f"MOTD: '{motd}'") + if max_u > 0: + parts.append(f"Kapasitas: {max_u} pengguna") + if ver: + parts.append(f"Versi server: {ver}") + return { + "status": "success", + "message": f"Properti server: {', '.join(parts)}.", + } + + elif tool_name == "kick_channel_users": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh kick orang.", + } + channel_target = str(tool_args.get("channel") or "").strip() + reason = str(tool_args.get("reason") or "").strip() + cid = self._find_channel_id(channel_target, context) + if not cid: + return { + "status": "error", + "error": "CHANNEL_NOT_FOUND", + "message": f"Channel '{channel_target}' tidak ditemukan di server.", + } + myid = self.getMyUserID() or 0 + targets = [u for u in self.getServerUsers() if u.nChannelID == cid and u.nUserID != myid] + cpath = self.getChannelPath(cid) or channel_target + if not targets: + return { + "status": "success", + "message": f"Tidak ada pengguna lain di channel {cpath} untuk ditendang.", + } + kicked = [] + for u in targets: + nick = from_tt_char(u.szNickname) + if reason: + safe_call(self.send_pm, u.nUserID, reason) + kicked.append(nick) + self._schedule_action( + self._now() + 0.1, + self._execute_manual_kick, + u.nUserID, + requester_id, + nick, + reason, + True, + ) + return { + "status": "success", + "message": f"Berhasil menendang {len(kicked)} user ({', '.join(kicked)}) dari channel {cpath}.", + } + elif tool_name == "get_bot_info": ver = getattr(config, "VERSION", "1.1.2") try: