diff --git a/README.md b/README.md index 200b080..5da4129 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@ graph TB subgraph bot[status-im/status-python-sdk] - REQUIREMENTS[requirements.txt] GROUP_CHAT[class GroupChat] SDK[class Account] SIGNAL[class Signal] diff --git a/examples/group-chat-moderator/README.md b/examples/group-chat-moderator/README.md new file mode 100644 index 0000000..0c0def7 --- /dev/null +++ b/examples/group-chat-moderator/README.md @@ -0,0 +1,104 @@ +# Group Chat Moderator + +An **automatic moderator** for a [Group Chat](https://status.app/help/messaging/create-a-group-chat). The script logs into a Status account, listens for new messages **in real time**, and scores each one for toxicity with [Detoxify](https://github.com/unitaryai/detoxify) (local model). Authors of toxic messages are **warned**, and after a specified number of warnings they are **removed** from the chat. + +## How it works + +Every message that lands in the chat is handled in its **own thread** by [`check_message`](./main.py). The thread: + +1. Skips messages sent by the bot itself. +2. Scores the message text with [Detoxify](https://github.com/unitaryai/detoxify) and takes the highest label (`toxicity`, `insult`, `threat`, ...). +3. Ignores anything below the `threshold` (default `0.6`). +4. Otherwise increments the author's warning count under a lock - the `warnings` dict is shared across threads, so the read-modify-write must be atomic. +5. Sends a warning reply, or [`remove`](../../docs/group-chat.md#removepublic_keys)s the author once they hit the `warning_limit` (default `3`). + +```mermaid +sequenceDiagram + actor Member as Group Chat Member + participant Listen as listen_messages + participant Check as check_message thread + participant Model as Detoxify model + participant Warnings as warning counts + + Member->>Listen: sends message + Listen->>Check: spawns per-message thread + Check->>Model: score message text + Model-->>Check: highest label + score + + alt Below Threshold + Check-->>Check: ignore + else At / Above Threshold + Check->>Warnings: increment author's count + Warnings-->>Check: current count + alt count < warning_limit + Check->>Member: send warning reply + else count >= warning_limit + Check->>Member: remove from chat + end + end +``` + + +This is just one moderation policy. [`check_message`](./main.py) is self-contained, so you can rewrite it for your own use case - swap in a different model or keyword filter, adjust `threshold` and `warning_limit` or escalate through different labels. The listener loop stays the same and only the per-message logic changes. + +**Note**: Removing members requires the account to be the [administrator](../../docs/group-chat.md#administrator) of the chat. See [Moderation power](./README.md#moderation-power). + +## Setup + +### 1. Install + +From the **repository root**, install the SDK with the `group-chat-moderator` dependencies: + +``` +pip install ".[group-chat-moderator]" +``` + +This pulls in [Detoxify](https://github.com/unitaryai/detoxify) and its [PyTorch](https://pytorch.org/) backend. The first run downloads the model weights. + + +**Note**: `detoxify` installs the CPU build of PyTorch by default. For faster inference on a CUDA GPU, uninstall `torch` and `torchvision`, then reinstall the GPU builds by following the instructions on [PyTorch's website](https://pytorch.org/). + +### 2. Configure + +Copy [`env.example`](./env.example) to `.env` in this folder and fill it in: + +``` +cp env.example .env +``` + +| Variable | What it is | +|-----|-------------| +| `PASSWORD` | The password of the moderating Status account. | +| `NAME` | The [display name](../../docs/account.md#display-name) or ENS name of the account. If you have previously logged in with the SDK you can provide an ENS. For first time log ins, it is best to provide a [display name](../../docs/account.md#display-name). | +| `MNEMONIC` | The [recovery phrase](https://status.app/help/profile/understand-your-status-keys-and-recovery-phrase) of the account. Used to recover it into the container. | +| `GROUP_CHAT_ID` | The `id` of the group chat to moderate. Group chat IDs come from the [`chats`](../../docs/account.md#chats) property, where `type` is `group_chat`. | + +### 3. Run + +The script loads its `.env` from the current directory, so run it from inside this folder: + +``` +cd examples/group-chat-moderator +python main.py +``` + +On the first run, [`launch_docker_container`](../../docs/utils.md#launch_docker_container) builds the Status Backend image, which takes a few minutes. Tthe bot starts listening: + +``` +[INFO] Successfully logged in! +[INFO] Loading Detoxify [cpu] +[INFO] Listening Group Chat Status Bots +``` + +Detoxify runs on the **GPU** automatically when CUDA is available (`[cuda]` above), and falls back to the CPU otherwise. The bot runs until you stop it with `Ctrl+C`. + +## Moderation power + +**This account acts as the moderator of the group chat.** To warn members it only needs to be in the chat, but to **remove** them it must be the [administrator](../../docs/group-chat.md#administrator) - only the admin can remove members. Point the moderator at a chat it created (or was made admin of), otherwise removals are rejected and members can only be warned. + +The moderation logic in this example is deliberately simple: + +- **One model, one threshold.** Every message is scored by [Detoxify](https://github.com/unitaryai/detoxify); anything scoring `0.6` or higher on any label counts as toxic. Tune `threshold` and `warning_limit` in [`check_message`](./main.py) to make moderation stricter or more lenient. +- **Warnings are per public key.** The count lives only in memory, so restarting the bot resets everyone's warnings to zero. + +Detoxify is a machine-learning model and will make mistakes - both false positives and false negatives. Treat it as a first line of moderation, not a final judge. diff --git a/examples/group-chat-moderator/env.example b/examples/group-chat-moderator/env.example new file mode 100644 index 0000000..32ac755 --- /dev/null +++ b/examples/group-chat-moderator/env.example @@ -0,0 +1,7 @@ +# Status Account setup +PASSWORD = "your-password-here" +NAME = "status-display-name" +MNEMONIC = "phrase_1 phrase_2 phrase_3 phrase_4 phrase_5 phrase_6 phrase_7 phrase_8 phrase_9 phrase_10 phrase_11 phrase_12" + +# Monitor messages from public chat +GROUP_CHAT_ID = "group-chat-id" diff --git a/examples/group-chat-moderator/main.py b/examples/group-chat-moderator/main.py new file mode 100644 index 0000000..9875d0a --- /dev/null +++ b/examples/group-chat-moderator/main.py @@ -0,0 +1,64 @@ +from dotenv import load_dotenv +from status_sdk import Account, GroupChat, launch_docker_container +from detoxify import Detoxify +import os, threading, torch + +# `warnings` is shared by every check_message thread, so the +# read-modify-write of a member's warning count must be atomic +warnings_lock = threading.Lock() + +def check_message(account: Account, message: dict, warnings: dict, group_chat: GroupChat, model: Detoxify, threshold: float = 0.6, warning_limit: int = 3): + """ + Score a single message and warn (or remove) its author. + """ + public_key = message["from"] + if public_key == account.info["public_key"]: + return + + label, score = max(model.predict(message["text"]).items(), key=lambda item: item[1]) + account.logger.info(f"Message: '{message['text']}'\t\t{label} - {(score * 100):.2f}%") + + if score < threshold: + return + + with warnings_lock: + warnings[public_key] = warnings.get(public_key, 0) + 1 + count = warnings[public_key] + + if count < warning_limit: + group_chat.send_message(f"Warning {count} /{warning_limit} - @{public_key} please keep it civil.", message["id"]) + account.logger.info(f"Sent warning to {public_key}") + elif count >= warning_limit: + group_chat.send_message(f"Removing @{public_key} member after {warning_limit} warnings.") + account.logger.info(f"Removed {public_key} from {group_chat.name}") + group_chat.remove(public_key) + +def main(): + launch_docker_container() + load_dotenv() + account = Account(backup_folder=os.path.dirname(__file__)) + account.login( + password=os.environ["PASSWORD"], + name=os.environ["NAME"], + mnemonic=os.environ["MNEMONIC"] + ) + group_chat = GroupChat(account, os.environ["GROUP_CHAT_ID"]) + warnings = {} + + device = "cuda" if torch.cuda.is_available() else "cpu" + account.logger.info(f"Loading Detoxify [{device}]") + model = Detoxify("original", device=device) + account.logger.info(f"Listening Group Chat {group_chat.name}") + for message in account.listen_messages(): + for chat in message["event"]["chats"]: + if chat["id"] != group_chat.id: + continue + + threading.Thread( + target=check_message, + args=(account, chat["lastMessage"], warnings, group_chat, model), + daemon=True + ).start() + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 361afc2..9584874 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,10 @@ agents = [ "langchain-groq", "python-dotenv", ] +group-chat-moderator = [ + "detoxify", + "python-dotenv" +] [project.urls] Source = "https://github.com/status-im/status-python-sdk"