From 138bed0e19e4a4815beed2d7e8fa738b6ed7438d Mon Sep 17 00:00:00 2001 From: "fian@DESKTOP-JAO9LN0." Date: Mon, 5 Oct 2026 10:26:56 +0700 Subject: [PATCH] added function calling. --- CHANGELOG.md | 1 + README.md | 2 +- ai_chat.py | 541 +++++++++++++++++++++++++++++++++++++++++-- docs/ai.md | 29 +++ tt_client.py | 635 ++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 1183 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f956aa..d4c1d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added - `@ai ` in a channel: the AI (Cloudflare Workers AI, `ai_chat.py`) answers in that channel, as short plain text for screen readers, followed by `AI_CHAT_DISCLAIMER` ("AI bisa salah. Periksa kembali info penting."). It remembers the channel's last few questions for follow-ups, never gets nicknames, and has a per-person cooldown and a daily limit; answers with clear badwords are replaced by a refusal. Off by default (`/abt aichat`); users can read `/help ai`. +- Natural language bot commands via Function Calling for `@ai`: users can speak commands naturally (e.g. `@ai matiin word filter dong`, `@ai matiin moderation`, `@ai kick orang yang namanya kak fian dong`, `@ai siapa aja yang lagi online?`). Features 18 distinct tools covering moderation toggling (`toggle_moderation_feature`, `get_moderation_status`, `forgive_user`, `temp_ban_user`, `unban_user`), user management, badwords list management, and server inspection. Sensitive operations strictly verify admin privileges (`USERTYPE_ADMIN`), rejecting unauthorized calls with a Gen Z refusal. If an unsupported action is requested, the AI informs the user that the function is not yet available. Includes safe cross-thread tool dispatching and screen reader sanitized plain-text output. - Guest accounts (`tamu`, `hadirin`) are blocked from using `@ai ` in channels by default (`AI_CHAT_BLOCKED_USERS`), with a private notification (`AI_CHAT_BLOCKED_MESSAGE`). - Optional AI context check (`ai_review.py`, Cloudflare Workers AI): when every badword in a message is an ambiguous one (`AI_AMBIGUOUS_WORDS`, e.g. anjing, babi, tahi, telanjang, gay), the bot asks the AI whether it was an insult instead of counting it right away, so "anjing tetanggaku berisik" is not punished but "anjing lah" is. The AI also sees up to `AI_CONTEXT_MESSAGES` earlier messages and two follow-ups (kept in memory only, without names), so a lone "anjing" after "aku punya binatang baru" is read as the pet. Runs in the background; if the AI does not answer the message is not counted. Off by default, switched with `/abt ai` (refused until the credentials are in `config.json`); `/bwt` shows the AI's verdict; `tools/ai_eval.py` measures a model on 42 labelled sentences. Setup: `docs/ai.md`. - GitHub Actions workflow (`.github/workflows/deploy.yml`): pull requests into `main` are checked (all Python files must compile), and merging into `main` updates and restarts the bot on the server over SSH through `tools/deploy.sh`, using a key that can only run that script. Setup: `docs/deploy.md`. diff --git a/README.md b/README.md index 5d8b154..5f9971e 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ All wizards support `/cancel` to abort. Output is chunked automatically if respo Optionally, an AI model on Cloudflare Workers AI decides whether an ambiguous badword was meant as an insult ("anjing tetanggaku berisik" is about a dog, "anjing lah" is a curse). Only messages whose matches are all ambiguous are sent, with a few surrounding messages as context (so a lone "anjing" after "aku punya binatang baru" is understood), without names or IPs; if the AI does not answer, the message is not counted. Setup and evaluation: [`docs/ai.md`](docs/ai.md). Switch: `/abt ai`. -Users can also ask the AI in a channel: a message starting with `@ai` gets an answer in that channel, followed by a short "AI can make mistakes" note (`/abt aichat`, `/help ai`). +Users can also ask the AI in a channel: a message starting with `@ai` gets an answer in that channel, followed by a short "AI can make mistakes" note (`/abt aichat`, `/help ai`). It also supports natural language bot commands via function calling (e.g. `@ai kick orang yang namanya kak fian dong`), with admin permission checks on sensitive actions. ## Deployment diff --git a/ai_chat.py b/ai_chat.py index 3aa0333..94331b7 100644 --- a/ai_chat.py +++ b/ai_chat.py @@ -2,44 +2,346 @@ Like ai_review, the HTTP call runs on a background thread; answers come back through a queue and are posted to the channel from the main thread. +Supports function calling to execute bot commands safely on the main thread. """ +import json import logging import queue import re import threading -from typing import Any, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from ai_review import API_URL, DEFAULT_MODEL, call_workers_ai logger = logging.getLogger(__name__) -SYSTEM_PROMPT = """You are the assistant of a TeamTalk voice-chat server run by a school for blind students in Indonesia. People type questions to you in a channel, and your answer is 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, and your answers are read aloud by screen readers to everyone in that channel. +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. +Answer in the language of the question (usually Indonesian). +Formatting Rules (CRITICAL): +Plain text ONLY. +STRICTLY NO Markdown (no asterisks for bold, no dashes/bullet points, no hashtags), no tables, and no emojis. +Do not include links unless specifically asked. +Remember: screen readers read punctuation and symbols aloud, so keep your text clean, flowing, and conversational. +Response Guidelines: +Keep it brief: about 4 sentences maximum, unless someone explicitly asks for more details. +You cannot hear the voice chat or see anything. You only have access to the typed text and the channel's chat history. +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. -- Answer in the language of the question (usually Indonesian), in a friendly way that suits students. -- Keep it short: about 4 sentences at most, unless someone asks for more detail. -- Plain text only: no Markdown, no symbols for bold or lists, no tables, no emoji, no links unless asked. Screen readers read symbols aloud. -- You cannot hear the voice chat or see anything. You only get the typed question and, if there are any, the earlier questions and your answers in this channel. -- Briefly refuse anything sexual, hateful, dangerous or meant to hurt someone, and never insult anyone. -- If you are not sure about something, say so instead of guessing.""" +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. +- 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. + - 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.""" # Characters screen readers read aloud from Markdown formatting -_MARKDOWN = re.compile(r"[*#`_]{1,3}") +_MARKDOWN = re.compile(r"[*#`_~]{1,3}") +# Emojis screen readers read aloud; strip them +_EMOJI = re.compile( + r"[\U00010000-\U0010ffff]|[\u2600-\u27bf]|[\ud83c-\ud83e][\ud000-\udfff]" +) def clean_answer(text: str) -> str: - """Strip Markdown symbols and extra blank lines from a model answer.""" + """Strip Markdown symbols, emojis, and extra blank lines from a model answer.""" lines = [] for line in str(text or "").splitlines(): line = _MARKDOWN.sub("", line).strip() + line = _EMOJI.sub("", line).strip() line = re.sub(r"^[-•]\s+", "", line) if line: lines.append(line) return "\n".join(lines) +TOOLS = [ + { + "name": "kick_user", + "description": "Kick a user from the TeamTalk server by nickname. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "nickname": { + "type": "string", + "description": "The nickname of the user to kick.", + }, + "reason": { + "type": "string", + "description": "Optional reason for kicking the user.", + }, + }, + "required": ["nickname"], + }, + }, + { + "name": "ban_user", + "description": "Ban a user permanently from the TeamTalk server by nickname. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "nickname": { + "type": "string", + "description": "The nickname of the user to ban.", + }, + "reason": { + "type": "string", + "description": "Optional reason for banning the user.", + }, + }, + "required": ["nickname"], + }, + }, + { + "name": "temp_ban_user", + "description": "Temporarily ban a user for a specific duration in minutes. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "nickname": { + "type": "string", + "description": "The nickname of the user to temporarily ban.", + }, + "minutes": { + "type": "integer", + "description": "Duration of the ban in minutes (e.g. 5, 10, 60).", + }, + "reason": { + "type": "string", + "description": "Optional reason for the temporary ban.", + }, + }, + "required": ["nickname", "minutes"], + }, + }, + { + "name": "unban_user", + "description": "Unban a user by username or IP address. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "The username or IP address to unban.", + }, + }, + "required": ["target"], + }, + }, + { + "name": "move_user", + "description": "Move a user to a different channel by nickname and channel name/path. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "nickname": { + "type": "string", + "description": "The nickname of the user to move.", + }, + "channel": { + "type": "string", + "description": "The name or path of the destination channel.", + }, + }, + "required": ["nickname", "channel"], + }, + }, + { + "name": "change_bot_status", + "description": "Change the bot's status message. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "The new status message.", + }, + }, + "required": ["status"], + }, + }, + { + "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.", + "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.", + }, + "enabled": { + "type": "boolean", + "description": "True to enable/turn on (hidupkan/aktifkan), False to disable/turn off (matikan/nonaktifkan).", + }, + }, + "required": ["feature", "enabled"], + }, + }, + { + "name": "get_moderation_status", + "description": "Check current ON/OFF status of all server moderation and protection features. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "forgive_user", + "description": "Clear warnings and lift active temporary bans for a user, nickname, username, IP, or 'all'. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "The nickname, username, IP, or 'all' to forgive.", + }, + }, + "required": ["target"], + }, + }, + { + "name": "add_badword", + "description": "Add new word(s) or patterns to the word filter / badword list. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "words": { + "type": "string", + "description": "Word(s) to add, comma-separated if multiple.", + }, + }, + "required": ["words"], + }, + }, + { + "name": "delete_badword", + "description": "Remove word(s) from the word filter / badword list. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "words": { + "type": "string", + "description": "Word(s) to remove, comma-separated if multiple.", + }, + }, + "required": ["words"], + }, + }, + { + "name": "list_badwords", + "description": "List or search entries in the server's word filter / badword list. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Optional search term to filter words.", + }, + }, + }, + }, + { + "name": "list_bans", + "description": "List active bans and temporary bans on the server. Sensitive: requires admin privileges.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "list_online_users", + "description": "List all users currently connected/online on the server.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "list_channels", + "description": "List all channels on the server.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "find_user", + "description": "Find a user on the server to check which channel they are in and their status message.", + "parameters": { + "type": "object", + "properties": { + "nickname": { + "type": "string", + "description": "The nickname of the user to find.", + }, + }, + "required": ["nickname"], + }, + }, + { + "name": "check_channel_owner", + "description": "Check who is the registered owner of a channel.", + "parameters": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "The channel name or path to check.", + }, + }, + "required": ["channel"], + }, + }, + { + "name": "get_bot_info", + "description": "Get general information about the bot, version, and supported capabilities.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, +] + + +class ToolRequest: + """Thread-safe request to execute a tool on the main thread.""" + + def __init__(self, name: str, args: dict, context: Any) -> None: + self.name = name + self.args = args + self.context = context + self.result: Optional[Dict[str, Any]] = None + self.event = threading.Event() + + class AIChat: - """Ask Workers AI questions on a background thread.""" + """Ask Workers AI questions on a background thread with function calling support.""" MAX_PENDING = 5 @@ -60,6 +362,7 @@ def __init__( self._api_url = api_url self._jobs: "queue.Queue[Tuple[str, list, Any]]" = queue.Queue(self.MAX_PENDING) self._results: "queue.Queue[Tuple[Any, Optional[str]]]" = queue.Queue() + self._tool_requests: "queue.Queue[ToolRequest]" = queue.Queue() self._thread: Optional[threading.Thread] = None @property @@ -88,35 +391,227 @@ def drain(self) -> List[Tuple[Any, Optional[str]]]: except queue.Empty: return done + def drain_tool_requests(self) -> List[ToolRequest]: + """Pending tool execution requests; call from the main thread.""" + reqs = [] + while True: + try: + reqs.append(self._tool_requests.get_nowait()) + except queue.Empty: + return reqs + + def request_tool(self, name: str, args: dict, context: Any) -> Dict[str, Any]: + """Ask the main thread to execute a tool and wait for its result.""" + req = ToolRequest(name, args, context) + self._tool_requests.put(req) + if req.event.wait(timeout=5.0): + return req.result or {} + return {"status": "error", "error": "TIMEOUT", "message": "Eksekusi fungsi timed out."} + def _run(self) -> None: while True: question, history, context = self._jobs.get() try: - answer: Optional[str] = self.answer(question, history) + answer: Optional[str] = self.answer(question, history, context) except Exception as exc: logger.warning("AI chat failed: %s", exc) answer = None self._results.put((context, answer)) self._jobs.task_done() - def answer(self, question: str, history: List[Tuple[str, str]]) -> str: - """Ask the model (blocking) and return its cleaned answer; raises on failure.""" - messages = [{"role": "system", "content": SYSTEM_PROMPT}] + 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.""" + if not isinstance(result, dict): + return None + tool_calls = result.get("tool_calls") + if not tool_calls and isinstance(result.get("response"), dict): + tool_calls = result["response"].get("tool_calls") + if not tool_calls and isinstance(result.get("choices"), list) and result["choices"]: + tool_calls = result["choices"][0].get("message", {}).get("tool_calls") + + 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") + args = call.get("arguments", {}) + if isinstance(args, str): + try: + args = json.loads(args) + except Exception: + args = {} + if name: + 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"')) + ): + decoder = json.JSONDecoder() + idx = 0 + length = len(raw_text) + while idx < length: + start_brace = raw_text.find("{", idx) + if start_brace == -1: + break + try: + obj, end_pos = decoder.raw_decode(raw_text[start_brace:]) + if isinstance(obj, dict): + if "tool_calls" in obj and isinstance(obj["tool_calls"], list): + extracted = self._extract_tool_calls(obj) + if extracted: + return extracted + t_name = obj.get("name") or obj.get("function") or obj.get("tool") or obj.get("action") + 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: + return [{"name": t_name, "arguments": t_args, "id": "call_1"}] + idx = start_brace + max(1, end_pos) + except Exception: + idx = start_brace + 1 + 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.""" + messages: List[Dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}] for earlier_question, earlier_answer in history: messages.append({"role": "user", "content": earlier_question}) messages.append({"role": "assistant", "content": earlier_answer}) messages.append({"role": "user", "content": question}) - payload = call_workers_ai( - self._api_url, - self._account_id, - self._api_token, - self.model, - {"messages": messages, "max_tokens": self._max_tokens, "temperature": 0.4}, - self._timeout, - ) + + body = { + "messages": messages, + "tools": TOOLS, + "max_tokens": self._max_tokens, + "temperature": 0.4, + } + try: + payload = call_workers_ai( + self._api_url, + self._account_id, + self._api_token, + self.model, + body, + self._timeout, + ) + except Exception: + # Fallback without tools if model/endpoint dislikes tools parameter + body.pop("tools", None) + payload = call_workers_ai( + self._api_url, + self._account_id, + self._api_token, + self.model, + body, + self._timeout, + ) + if not payload.get("success", True): raise RuntimeError(f"Workers AI error: {payload.get('errors')}") + result = payload.get("result") or {} + tool_calls = self._extract_tool_calls(result) + + if tool_calls: + call = tool_calls[0] + tool_name = call["name"] + tool_args = call.get("arguments", {}) + call_id = call.get("id") or "call_1" + + logger.info("Executing AI tool call: %s(%s)", tool_name, tool_args) + tool_result = self.request_tool(tool_name, tool_args, context) + logger.info("AI tool call result for %s: %s", tool_name, tool_result) + + # Try follow-up with tool role + followup_messages = list(messages) + followup_messages.append({ + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": call_id, + "name": tool_name, + "arguments": tool_args, + } + ], + }) + followup_messages.append({ + "role": "tool", + "name": tool_name, + "tool_call_id": call_id, + "content": json.dumps(tool_result, ensure_ascii=False), + }) + try: + f_payload = call_workers_ai( + self._api_url, + self._account_id, + self._api_token, + self.model, + { + "messages": followup_messages, + "max_tokens": self._max_tokens, + "temperature": 0.4, + }, + self._timeout, + ) + if f_payload.get("success", True): + f_res = f_payload.get("result") or {} + final_ans = clean_answer(f_res.get("response") if isinstance(f_res, dict) else "") + if final_ans: + return final_ans + except Exception as exc: + logger.warning("Follow-up with role 'tool' failed: %s", exc) + + # Fallback follow-up with user prompt + try: + alt_messages = list(messages) + alt_messages.append({ + "role": "assistant", + "content": f"Aku akan menjalankan fungsi {tool_name}.", + }) + alt_messages.append({ + "role": "user", + "content": f"[Hasil eksekusi {tool_name}]: {json.dumps(tool_result, ensure_ascii=False)}. Berikan respon santai dan ramah khas Gen Z dalam bahasa Indonesia ke user.", + }) + alt_payload = call_workers_ai( + self._api_url, + self._account_id, + self._api_token, + self.model, + { + "messages": alt_messages, + "max_tokens": self._max_tokens, + "temperature": 0.4, + }, + self._timeout, + ) + if alt_payload.get("success", True): + alt_res = alt_payload.get("result") or {} + final_ans = clean_answer(alt_res.get("response") if isinstance(alt_res, dict) else "") + if final_ans: + return final_ans + except Exception as exc: + logger.warning("Alternative follow-up failed: %s", exc) + + # Direct fallback if second LLM turn unavailable + if tool_result.get("error") == "PERMISSION_DENIED": + return "Woi kamu bukan admin bro, gak boleh aneh-aneh ya." + if tool_result.get("error") == "UNKNOWN_TOOL": + return "Wah kayaknya belum ada deh function buat itu di bot ini." + if tool_result.get("status") == "success": + return tool_result.get("message") or "Udah beres aku lakuin ya." + return tool_result.get("message") or "Maaf, perintah tadi gagal diproses." + text = clean_answer(result.get("response") if isinstance(result, dict) else "") if not text: raise ValueError("empty answer from the model") diff --git a/docs/ai.md b/docs/ai.md index 653e9ed..2beff05 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -115,6 +115,34 @@ AI bisa salah. Periksa kembali info penting. - In classroom channels the server may refuse the bot's answer unless the bot is allowed to write there. +### Natural language commands & function calling + +Users can request actions in natural conversation without remembering manual slash commands (for example: `@ai matiin word filter dong`, `@ai matiin moderation`, `@ai kick orang yang namanya kak fian dong`, or `@ai siapa aja yang lagi online?`). + +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). +- `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). +- `forgive_user`: clear warnings and lift temp bans for a user or all users (sensitive, requires admin). +- `add_badword`: add word(s) to the word filter (sensitive, requires admin). +- `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). +- `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). +- `find_user`: locate a user and see what channel they are in and their status (available to everyone). +- `check_channel_owner`: check the owner of a channel (available to everyone). +- `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"*). + +**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"*). + Setup: the same Cloudflare keys as above, then `/abt aichat on` (off by default). Other settings: `AI_CHAT_PREFIX` (default `@ai`), `AI_CHAT_MODEL` (empty: same as `AI_MODEL`), `AI_CHAT_MAX_TOKENS` (default 300), @@ -122,3 +150,4 @@ default). Other settings: `AI_CHAT_PREFIX` (default `@ai`), `AI_CHAT_MODEL` `AI_CHAT_BLOCKED_MESSAGE` and `AI_CHAT_DISCLAIMER` (the line after every answer; empty to leave it out). Users can read `/help ai`. + diff --git a/tt_client.py b/tt_client.py index 344b1a6..50903d4 100644 --- a/tt_client.py +++ b/tt_client.py @@ -2627,7 +2627,13 @@ def _handle_ai_question(self, userid: int, channel_id: int, question: str): ] user = safe_call(self.getUser, userid) nickname = from_tt_char(user.szNickname).strip() if user is not None else "" - context = {"userid": userid, "channel": channel_id, "nickname": nickname, "question": question} + context = { + "userid": userid, + "channel": channel_id, + "nickname": nickname, + "question": question, + "is_admin": self._is_admin(userid), + } if not self._ai_chat.ask(question, history, context): safe_call(self.send_pm, userid, "AI sedang sibuk, coba lagi sebentar lagi.") return @@ -2679,6 +2685,632 @@ def _send_channel_text(self, channel_id: int, text: str): except Exception: logger.exception("Failed to send a message to channel %s", channel_id) + def _find_users_by_nickname_fuzzy(self, name: str) -> List[User]: + clean_name = str(name or "").strip().lower() + if not clean_name: + return [] + users = self.getServerUsers() + # 1. Exact match + exact = [u for u in users if from_tt_char(u.szNickname).strip().lower() == clean_name] + if exact: + return exact + # 2. Match without honorifics/prefixes like "kak ", "bang ", "mas ", "pak ", "bu " + without_prefix = re.sub(r"^(kak|bang|mas|pak|bu|om|tante)\s+", "", clean_name).strip() + if without_prefix and without_prefix != clean_name: + prefix_match = [ + u for u in users if from_tt_char(u.szNickname).strip().lower() == without_prefix + ] + if prefix_match: + return prefix_match + # 3. Substring match + partial = [ + u for u in users + if clean_name in from_tt_char(u.szNickname).strip().lower() + or from_tt_char(u.szNickname).strip().lower() in clean_name + or (without_prefix and without_prefix in from_tt_char(u.szNickname).strip().lower()) + ] + if partial: + return partial + return [] + + def _find_channel_id(self, target: str) -> Optional[int]: + target_str = str(target or "").strip() + if not target_str: + return None + # Try direct path or normalized path + for p in (target_str, self._normalize_channel_target_to_path(target_str)): + try: + cid = self.getChannelIDFromPath(p) + if cid and cid > 0: + return cid + except Exception: + pass + # Search all channels by name or path + try: + target_lower = target_str.lower().strip("/") + clean_chan = re.sub(r"^channel\s+", "", target_lower).strip() + for ch in self.getServerChannels(): + name = from_tt_char(ch.szName).lower().strip() + path = (self.getChannelPath(ch.nChannelID) or "").lower().strip("/") + if clean_chan in (name, path) or path.endswith(f"/{clean_chan}"): + return ch.nChannelID + except Exception: + pass + return None + + def _execute_ai_tool(self, tool_name: str, tool_args: dict, context: Any) -> Dict[str, Any]: + requester_id = context.get("userid", 0) if isinstance(context, dict) else 0 + is_admin = self._is_admin(requester_id) + + if tool_name == "kick_user": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh kick orang.", + } + nickname = str(tool_args.get("nickname") or "").strip() + reason = str(tool_args.get("reason") or "").strip() + targets = self._find_users_by_nickname_fuzzy(nickname) + if not targets: + return { + "status": "error", + "error": "USER_NOT_FOUND", + "message": f"User '{nickname}' tidak ditemukan di server.", + } + myid = self.getMyUserID() or 0 + kicked = [] + for u in targets: + if u.nUserID == myid: + continue + 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, + ) + if not kicked: + return {"status": "error", "message": "Tidak bisa menendang bot sendiri."} + return { + "status": "success", + "message": f"User {', '.join(kicked)} berhasil ditendang dari server.", + } + + elif tool_name == "ban_user": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh ban orang.", + } + nickname = str(tool_args.get("nickname") or "").strip() + reason = str(tool_args.get("reason") or "").strip() + targets = self._find_users_by_nickname_fuzzy(nickname) + if not targets: + return { + "status": "error", + "error": "USER_NOT_FOUND", + "message": f"User '{nickname}' tidak ditemukan di server.", + } + mode = str(getattr(config, "BAN_TARGET", "USERNAME")).upper() + ban_type = BanType.BANTYPE_USERNAME if mode == "USERNAME" else BanType.BANTYPE_IPADDR + myid = self.getMyUserID() or 0 + banned = [] + for u in targets: + if u.nUserID == myid: + continue + who = from_tt_char(u.szUsername) if mode == "USERNAME" else from_tt_char(u.szIPAddress) + nick = from_tt_char(u.szNickname) + if reason: + safe_call(self.send_pm, u.nUserID, reason) + banned.append(nick) + self._schedule_action( + self._now() + 0.1, + self._execute_manual_ban, + u.nUserID, + requester_id, + int(ban_type), + who, + nick, + reason, + True, + ) + if not banned: + return {"status": "error", "message": "Tidak bisa memblokir bot sendiri."} + return { + "status": "success", + "message": f"User {', '.join(banned)} berhasil diblokir dari server.", + } + + elif tool_name == "move_user": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh mindahin orang.", + } + 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) + if not targets: + return { + "status": "error", + "error": "USER_NOT_FOUND", + "message": f"User '{nickname}' tidak ditemukan di server.", + } + cid = self._find_channel_id(channel_target) + if not cid: + return { + "status": "error", + "error": "CHANNEL_NOT_FOUND", + "message": f"Channel '{channel_target}' tidak ditemukan di server.", + } + moved = [] + for u in targets: + self.doMoveUser(u.nUserID, cid) + moved.append(from_tt_char(u.szNickname)) + chan_name = self.getChannelPath(cid) or str(cid) + return { + "status": "success", + "message": f"User {', '.join(moved)} berhasil dipindahkan ke channel {chan_name}.", + } + + elif tool_name == "list_online_users": + users = self.getServerUsers() + online = [] + for u in users: + nick = from_tt_char(u.szNickname) + if nick: + chan_path = self.getChannelPath(u.nChannelID) or "Root" + online.append(f"{nick} (di channel {chan_path})") + return { + "status": "success", + "users": online, + "total": len(online), + "message": f"Ada {len(online)} pengguna yang sedang online: {', '.join(online)}." if online else "Tidak ada pengguna lain yang online.", + } + + elif tool_name == "list_channels": + chans = self.getServerChannels() + skip_ids = set() if is_admin else self._hidden_channel_ids(chans) + names = [] + for ch in chans: + if ch.nChannelID not in skip_ids: + path = self.getChannelPath(ch.nChannelID) or from_tt_char(ch.szName) + if path: + names.append(path) + return { + "status": "success", + "channels": names[:25], + "total": len(names), + "message": f"Ada {len(names)} channel di server: {', '.join(names[:20])}.", + } + + elif tool_name == "change_bot_status": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh ganti status bot.", + } + status = str(tool_args.get("status") or "").strip() + if not status: + return {"status": "error", "message": "Pesan status tidak boleh kosong."} + self.doChangeStatus(0, status) + return { + "status": "success", + "message": f"Status bot berhasil diubah menjadi: '{status}'.", + } + + elif tool_name == "temp_ban_user": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh ban sementara.", + } + nickname = str(tool_args.get("nickname") or "").strip() + try: + minutes = max(1, int(tool_args.get("minutes") or 5)) + except Exception: + minutes = 5 + reason = str(tool_args.get("reason") or "").strip() + targets = self._find_users_by_nickname_fuzzy(nickname) + if not targets: + return { + "status": "error", + "error": "USER_NOT_FOUND", + "message": f"User '{nickname}' tidak ditemukan di server.", + } + myid = self.getMyUserID() or 0 + banned = [] + delay = 1.0 if reason else 0.05 + for u in targets: + if u.nUserID == myid: + continue + nick = from_tt_char(u.szNickname) + if reason: + safe_call(self.send_pm, u.nUserID, f"Kamu di-ban sementara {minutes} menit: {reason}") + banned.append(nick) + self._schedule_action( + self._now() + delay, + self._execute_manual_temp_ban, + u.nUserID, + from_tt_char(u.szIPAddress), + from_tt_char(u.szUsername), + nick, + int(minutes), + reason, + requester_id, + ) + if not banned: + return {"status": "error", "message": "Tidak bisa memblokir bot sendiri."} + return { + "status": "success", + "message": f"User {', '.join(banned)} berhasil di-ban sementara selama {minutes} menit.", + } + + elif tool_name == "unban_user": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh unban orang.", + } + target = str(tool_args.get("target") or "").strip() + if not target: + return {"status": "error", "message": "Target username atau IP tidak boleh kosong."} + by_ip = self._ban_target_is_ip() + descs = [] + if by_ip: + cmdid = self.doUnBanUser(target, 0) + if cmdid > 0: + self._track_pending_cmd(cmdid, requester_id, f"unban IP '{target}'") + descs.append(target) + else: + bu = BannedUser() + assign_tt_char_array((bu, "szUsername"), target) + bu.uBanTypes = BanType.BANTYPE_USERNAME + assign_tt_char_array((bu, "szIPAddress"), "") + assign_tt_char_array((bu, "szChannelPath"), "") + assign_tt_char_array((bu, "szNickname"), "") + assign_tt_char_array((bu, "szOwner"), "") + cmdid = self.doUnbanUserEx(bu) + if cmdid > 0: + self._track_pending_cmd(cmdid, requester_id, f"unban username '{target}'") + descs.append(target) + self._forget_temp_ban(target) + if descs: + return { + "status": "success", + "message": f"Unban untuk '{target}' berhasil diproses ke server.", + } + return { + "status": "error", + "message": f"Gagal memproses unban untuk '{target}'.", + } + + elif tool_name == "toggle_moderation_feature": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "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)) + action_str = "diaktifkan" if enabled else "dinonaktifkan" + if feat_raw in ("all", "moderation", "semua", "semua moderasi", "semua fitur"): + target_features = ["login", "join", "spam", "badwords", "profile", "pm"] + for f in target_features: + self._set_feature(f, enabled) + return { + "status": "success", + "message": f"Semua fitur moderasi (login, join, spam, badwords, profile, pm) berhasil {action_str}.", + } + + mapping = { + "word_filter": "badwords", + "word filter": "badwords", + "filter kata": "badwords", + "filter_kata": "badwords", + "badword": "badwords", + "badwords": "badwords", + "antispam": "spam", + "anti spam": "spam", + "anti_spam": "spam", + "spam": "spam", + "login": "login", + "join": "join", + "profile": "profile", + "pm": "pm", + "ai": "ai", + "aichat": "aichat", + "ai_chat": "aichat", + "chat_ai": "aichat", + } + target_feat = mapping.get(feat_raw, feat_raw) + known_features = {name for name, _desc, _key in self._FEATURES} + if target_feat not in known_features: + return { + "status": "error", + "error": "FEATURE_NOT_FOUND", + "message": f"Fitur '{feat_raw}' tidak dikenal. Pilihan fitur: word filter (badwords), spam, login, join, profile, pm, ai, aichat, atau all.", + } + + if target_feat in self._AI_FEATURES and enabled and not self._ai.configured: + return { + "status": "error", + "error": "AI_NOT_CONFIGURED", + "message": "Fitur AI belum dikonfigurasi di config.json.", + } + + notes = self._set_feature(target_feat, enabled) + desc = dict((n, d) for n, d, _ in self._FEATURES).get(target_feat, target_feat) + msg = f"Fitur {desc} ({target_feat}) berhasil {action_str}." + if notes: + msg += " " + " ".join(notes) + return { + "status": "success", + "message": msg, + } + + elif tool_name == "get_moderation_status": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh lihat status moderasi.", + } + lines = [] + for name, desc, _ in self._FEATURES: + state = "ON" if self._feature_on(name) else "OFF" + lines.append(f"{name}: {state}") + return { + "status": "success", + "message": f"Status moderasi server: {', '.join(lines)}.", + } + + elif tool_name == "forgive_user": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh memaafkan user.", + } + target = str(tool_args.get("target") or "").strip().lower() + if not target: + return {"status": "error", "message": "Target user atau IP harus diisi."} + now = self._now() + if target in ("all", "semua"): + cleared_warnings = 0 + for kind, key, _stage, _left in self._abuse.active_stages(): + self._abuse.reset(kind, key) + self._forgiven[key] = now + cleared_warnings += 1 + cleared_bans = len(self._temp_bans) + for entry in list(self._temp_bans.values()): + if entry.get("key"): + self._forgiven[entry["key"]] = now + self._lift_temp_ban(entry["mode"], entry["label"], entry.get("key", ""), entry.get("kind", ""), True) + return { + "status": "success", + "message": f"Semua sanksi berhasil dimaafkan ({cleared_warnings} peringatan direset, {cleared_bans} temp ban dicabut).", + } + + items = self._find_abuse_items(target) + if not items: + users = self._find_users_by_nickname_fuzzy(target) + for u in users: + items.extend(self._find_abuse_items(from_tt_char(u.szNickname))) + items.extend(self._find_abuse_items(from_tt_char(u.szUsername))) + items.extend(self._find_abuse_items(from_tt_char(u.szIPAddress))) + items = list(dict.fromkeys(items)) + if not items: + return { + "status": "error", + "message": f"Tidak ada peringatan atau temp ban aktif untuk '{target}'.", + } + done = [] + for item in items: + if item[0] == "warning": + _, kind, key = item + self._abuse.reset(kind, key) + self._forgiven[key] = now + done.append(f"{self._abuse_subject_label(kind, key)} (peringatan {kind} dihapus)") + else: + _, mode, label = item + entry = self._temp_bans.get(f"{mode}:{label}") + if entry: + if entry.get("key"): + self._forgiven[entry["key"]] = now + self._lift_temp_ban(mode, label, entry.get("key", ""), entry.get("kind", ""), True) + done.append(f"{entry.get('who') or label} (temp ban dicabut)") + return { + "status": "success", + "message": f"Berhasil dimaafkan: {'; '.join(done)}.", + } + + elif tool_name == "add_badword": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh nambah kata kasar.", + } + raw_words = str(tool_args.get("words") or "").strip() + words = self._parse_badword_csv(raw_words) + if not words: + return {"status": "error", "message": "Kata yang ingin ditambahkan tidak boleh kosong."} + errors = [err for err in map(self._badwords.pattern_error, words) if err] + added = self._badwords.add_words(words) + valid = {w for w in words if not self._badwords.pattern_error(w)} + already = sorted(valid - set(added)) + res_parts = [] + if added: + res_parts.append(f"Kata berhasil ditambahkan: {', '.join(added)}") + if already: + res_parts.append(f"Sudah ada di daftar: {', '.join(already)}") + if errors: + res_parts.append(f"Gagal ditambahkan: {', '.join(errors)}") + return { + "status": "success" if added else "error", + "message": ". ".join(res_parts) or "Tidak ada kata yang ditambahkan.", + } + + elif tool_name == "delete_badword": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh hapus kata kasar.", + } + raw_words = str(tool_args.get("words") or "").strip() + words = self._parse_badword_csv(raw_words) + if not words: + return {"status": "error", "message": "Kata yang ingin dihapus tidak boleh kosong."} + removed = self._badwords.remove_words(words) if words else [] + missing = sorted(set(words) - set(removed)) + res_parts = [] + if removed: + res_parts.append(f"Kata berhasil dihapus dari word filter: {', '.join(removed)}") + if missing: + res_parts.append(f"Tidak ditemukan di daftar: {', '.join(missing)}") + return { + "status": "success" if removed else "error", + "message": ". ".join(res_parts) or "Tidak ada kata yang dihapus.", + } + + elif tool_name == "list_badwords": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh lihat daftar kata terlarang.", + } + query = str(tool_args.get("query") or "").strip().lower() + all_words = self._badwords.list_words() + words = [w for w in all_words if query in w] if query else all_words + if not words: + return { + "status": "success", + "total": 0, + "message": f"Tidak ada kata terlarang yang cocok dengan '{query}'." if query else "Daftar kata terlarang kosong.", + } + sample = words[:15] + more = f" (dan {len(words) - 15} lainnya)" if len(words) > 15 else "" + return { + "status": "success", + "total": len(words), + "message": f"Ditemukan {len(words)} kata terlarang: {', '.join(sample)}{more}.", + } + + elif tool_name == "list_bans": + if not is_admin: + return { + "status": "error", + "error": "PERMISSION_DENIED", + "message": "Kamu bukan admin, jadi gak boleh lihat daftar ban.", + } + now = self._now() + bans = sorted(self._temp_bans.values(), key=lambda e: e.get("until", 0)) + if not bans: + return { + "status": "success", + "message": "Tidak ada pengguna yang sedang terkena ban sementara saat ini.", + } + items = [] + for entry in bans: + who = entry.get("who") or entry.get("label") + left = self._fmt_duration(entry.get("until", 0) - now) + reason = entry.get("reason") or "tidak ada alasan" + items.append(f"{who} (sisa {left}, alasan: {reason})") + return { + "status": "success", + "message": f"Daftar ban sementara: {', '.join(items)}.", + } + + elif tool_name == "find_user": + nickname = str(tool_args.get("nickname") or "").strip() + if not nickname: + return {"status": "error", "message": "Nama pengguna harus diisi."} + targets = self._find_users_by_nickname_fuzzy(nickname) + if not targets: + return { + "status": "error", + "error": "USER_NOT_FOUND", + "message": f"User '{nickname}' tidak ditemukan atau sedang tidak online di server.", + } + infos = [] + for u in targets: + nick = from_tt_char(u.szNickname) + cid = u.nChannelID + cpath = self.getChannelPath(cid) or "Root" + status_msg = from_tt_char(u.szStatusMsg).strip() + status_str = f", status: '{status_msg}'" if status_msg else "" + infos.append(f"{nick} ada di channel {cpath}{status_str}") + return { + "status": "success", + "message": f"Ditemukan: {'; '.join(infos)}.", + } + + elif tool_name == "check_channel_owner": + channel_target = str(tool_args.get("channel") or "").strip() + cid = self._find_channel_id(channel_target) + if not cid: + return { + "status": "error", + "error": "CHANNEL_NOT_FOUND", + "message": f"Channel '{channel_target}' tidak ditemukan.", + } + path = self.getChannelPath(cid) or str(cid) + owner = cache_store.get_owner(path) + if not owner: + return { + "status": "success", + "message": f"Channel {path} tidak memiliki pemilik yang tercatat.", + } + return { + "status": "success", + "message": f"Pemilik channel {path} adalah {owner}.", + } + + elif tool_name == "get_bot_info": + ver = getattr(config, "VERSION", "1.1.2") + try: + tt_ver = tt5.getVersion() if hasattr(tt5, "getVersion") else "5.x" + except Exception: + tt_ver = "5.x" + return { + "status": "success", + "message": f"Aku bot TeamTalk untuk membantu teman-teman di server ini. Versi bot {ver}, TeamTalk SDK {tt_ver}. Fitur yang tersedia: moderasi otomatis, deteksi spam, filter kata kasar, manajemen channel, dan bantuan cerdas melalui @ai.", + } + + else: + return { + "status": "error", + "error": "UNKNOWN_TOOL", + "message": f"Wah kayaknya belum ada deh function '{tool_name}' di bot ini.", + } + + def _process_ai_tool_requests(self): + """Execute pending AI tool requests on the main thread.""" + for req in self._ai_chat.drain_tool_requests(): + try: + req.result = self._execute_ai_tool(req.name, req.args, req.context) + except Exception as exc: + logger.exception("AI tool execution error for %s: %s", req.name, exc) + req.result = {"status": "error", "error": "EXECUTION_ERROR", "message": str(exc)} + finally: + req.event.set() + def _process_ai_results(self): """Act on finished AI reviews (runs on the main thread).""" for context, verdict in self._ai.drain(): @@ -2907,6 +3539,7 @@ def _schedule_action(self, when_ts: float, fn, *args, **kwargs): def process_scheduled(self): safe_call(self._process_ai_results) + safe_call(self._process_ai_tool_requests) safe_call(self._process_ai_chat_results) try: now = self._now()