From 23348e83496e99df173f07f67a5502e6db0f15f9 Mon Sep 17 00:00:00 2001 From: Joey Date: Thu, 6 Aug 2026 12:23:37 +0000 Subject: [PATCH] Initial commit --- Dockerfile | 47 +++++++ README.md | 288 ++++++++++++++++++++++++++++++++++++++ RUN.md | 12 ++ config.json | 9 ++ env | 3 + requirements.txt | 3 + run.sh | 8 ++ spam_remover.py | 349 +++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 719 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 RUN.md create mode 100644 config.json create mode 100644 env create mode 100644 requirements.txt create mode 100755 run.sh create mode 100755 spam_remover.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b238ddd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM debian:stable-slim +ARG SIGNAL_CLI_VER=0.14.7 +ARG TEMURIN_JDK_URL=https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.4%2B7/OpenJDK25U-jdk_x64_linux_hotspot_25.0.4_7.tar.gz +ARG TEMURIN_JDK_SHA256=e58fcdcd637b25c03ca84cbbcefc70d11efb8f4b4cbd05decc9f661769d77f94 +ARG JDK_MAJOR=25 +ENV JAVA_HOME=/usr/lib/jvm/java-${JDK_MAJOR} + +RUN apt update && apt -y upgrade +RUN apt install -y wget ca-certificates +RUN rm -rf /var/lib/apt/lists/* + +RUN wget -qO /tmp/jdk.tar.gz "$TEMURIN_JDK_URL" +RUN echo "${TEMURIN_JDK_SHA256} /tmp/jdk.tar.gz" | sha256sum -c - +RUN mkdir /tmp/temurin && tar xvzf /tmp/jdk.tar.gz -C /tmp/temurin +RUN mkdir -p "$JAVA_HOME" && mv /tmp/temurin/*/* "$JAVA_HOME"/ && rm -f /tmp/jdk.tar.gz + +RUN update-alternatives --install /usr/bin/java java ${JAVA_HOME}/bin/java ${JDK_MAJOR}0 +RUN update-alternatives --set java /usr/lib/jvm/java-${JDK_MAJOR}/bin/java + + +WORKDIR /opt +RUN wget https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VER}/signal-cli-${SIGNAL_CLI_VER}.tar.gz +RUN tar xvfz signal-cli-${SIGNAL_CLI_VER}.tar.gz +RUN ln -s /opt/signal-cli-${SIGNAL_CLI_VER} /opt/signal-cli + +RUN mkdir /app \ + && useradd app -u 6789 + + +COPY requirements.txt /app + +WORKDIR /app + +RUN apt update && apt install -y python3-pip +RUN pip install --break-system-packages --no-cache-dir -r /app/requirements.txt + +RUN apt update && apt-get install -y --no-install-recommends libmagic1 \ + && rm -rf /var/lib/apt/lists/* + +COPY . /app +RUN chown -R app /app +USER app + + +#ENTRYPOINT /opt/signal-cli/bin/signal-cli --config=/app/signal-cli-data +ENTRYPOINT /app/spam_remover.py +CMD /app/run.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..269c72a --- /dev/null +++ b/README.md @@ -0,0 +1,288 @@ +# Signal Spammer Auto-Removal + +Automatically removes spammers from your Signal group chats when they message in a designated honeypot group. + +## How It Works + +1. You have a "honeypot" group that's clearly not for humans (e.g., "AI Bot Testing" or "System Messages") +2. Spammers join all your groups and message in every chat +3. When a spammer messages in the honeypot group, this script detects them +4. The script removes them from **all** groups you admin (except the honeypot) +5. You get a notification about the removal + +The spammer never realizes they've been caught - they still see the honeypot group and think everything is working. + +## Prerequisites + +- **Java 25+** (required by signal-cli 0.14.x) +- **signal-cli 0.14.2+** (has the group member removal fix) +- **Python 3.9+** +- A Signal account registered with signal-cli + +## Step 1: Install Java + +signal-cli requires Java 25 or newer. Install OpenJDK: + +**macOS (Homebrew):** +```bash +brew install openjdk@21 +# or for Java 25+: +brew install openjdk +``` + +**Ubuntu/Debian:** +```bash +sudo apt update +sudo apt install openjdk-21-jdk +``` + +**Verify:** +```bash +java --version +# Should show 21+ +``` + +## Step 2: Install signal-cli + +**Option A: Download release (recommended)** + +```bash +# Download latest release (check https://github.com/AsamK/signal-cli/releases for current version) +cd /tmp +wget https://github.com/AsamK/signal-cli/releases/download/v0.14.5/signal-cli-0.14.5-Linux.tar.gz + +# Extract +tar xf signal-cli-0.14.5-Linux.tar.gz + +# Move to /opt +sudo mv signal-cli-0.14.5 /opt/signal-cli + +# Add to PATH +echo 'export PATH="/opt/signal-cli:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +**Option B: macOS with Homebrew** +```bash +brew install signal-cli +``` + +**Option C: Build from source** +```bash +git clone https://github.com/AsamK/signal-cli.git +cd signal-cli +./gradlew installDist +# Binary will be in build/install/signal-cli/bin/signal-cli +``` + +## Step 3: Register signal-cli with Your Signal Account + +**Important:** You need a phone number that can receive SMS or calls. This can be: +- Your main number (if you want to use your existing account) +- A secondary number (dedicated for this bot) + +**Register:** +```bash +# Using your phone number +signal-cli -a +1234567890 register + +# You'll receive an SMS with a verification code +signal-cli -a +1234567890 verify CODE_FROM_SMS +``` + +**Alternative - Link as secondary device:** +If you want to use your existing Signal account as a linked device: +```bash +signal-cli link +# Shows a URI - scan this QR code with your phone's Signal app +# Go to Signal Settings > Linked Devices > Link New Device +``` + +**Set a PIN (recommended):** +```bash +signal-cli -a +1234567890 setPin YOUR_PIN +``` + +## Step 4: Start signal-cli Daemon + +The script communicates with signal-cli via its JSON-RPC HTTP interface. + +```bash +# Start daemon on localhost:8080 +signal-cli -a +1234567890 daemon --http=localhost:8080 +``` + +**Keep this running.** You may want to run it as a systemd service: + +```bash +# Create systemd service +sudo tee /etc/systemd/system/signal-cli.service << 'EOF' +[Unit] +Description=Signal CLI Daemon +After=network.target + +[Service] +Type=simple +User=YOUR_USERNAME +ExecStart=/opt/signal-cli/bin/signal-cli -a +1234567890 daemon --http=localhost:8080 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF + +# Enable and start +sudo systemctl daemon-reload +sudo systemctl enable signal-cli +sudo systemctl start signal-cli + +# Check status +sudo systemctl status signal-cli +``` + +## Step 5: Get Your Honeypot Group ID + +1. Create the honeypot group in Signal (app or signal-cli) +2. Get the group's base64 ID: + +```bash +signal-cli -a +1234567890 listGroups --output=json | jq '.[] | select(.name=="YOUR_HONEYPOT_GROUP_NAME") | .id' +``` + +Or list all groups: +```bash +signal-cli -a +1234567890 listGroups --output=json | jq '.[] | {name, id}' +``` + +## Step 6: Install Python Dependencies + +```bash +cd /path/to/this/project +pip install -r requirements.txt +``` + +## Step 7: Configure + +Edit `config.json`: + +```json +{ + "signal_cli_endpoint": "http://localhost:8080", + "account": "+1234567890", + "honeypot_group_id": "BASE64_GROUP_ID_FROM_STEP_5", + "notify_self_number": "+1234567890", + "excluded_groups": [], + "banned_list_path": "banned.json", + "log_file": "spam_remover.log" +} +``` + +**Configuration options:** + +| Field | Description | +|-------|-------------| +| `signal_cli_endpoint` | URL where signal-cli daemon is running | +| `account` | Your signal-cli registered phone number | +| `honeypot_group_id` | Base64 ID of the honeypot group | +| `notify_self_number` | Phone number to receive removal notifications | +| `excluded_groups` | List of group IDs to never remove from (besides honeypot) | +| `banned_list_path` | Path to store banned spammers list | +| `log_file` | Path to log file | + +## Step 8: Run + +**Daemon mode (recommended):** +```bash +python spam_remover.py +``` + +This runs continuously, listening for messages in the honeypot group. + +**Manual ban:** +```bash +python spam_remover.py --ban +1987654321 +``` + +**Dry run (preview):** +```bash +python spam_remover.py --dry-run +``` + +**List banned:** +```bash +python spam_remover.py --list-banned +``` + +**Single check:** +```bash +python spam_remover.py --once +``` + +## Running as a Service + +Create a systemd service for the spam remover: + +```bash +sudo tee /etc/systemd/system/spam-remover.service << 'EOF' +[Unit] +Description=Signal Spammer Auto-Remover +After=network.target signal-cli.service +Requires=signal-cli.service + +[Service] +Type=simple +User=YOUR_USERNAME +WorkingDirectory=/path/to/this/project +ExecStart=/usr/bin/python3 spam_remover.py +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable spam-remover +sudo systemctl start spam-remover +``` + +## Files Created + +| File | Description | +|------|-------------| +| `spam_remover.py` | Main script | +| `config.json` | Configuration | +| `requirements.txt` | Python dependencies | +| `banned.json` | Auto-created, stores banned spammers | +| `spam_remover.log` | Auto-created, application logs | + +## Troubleshooting + +**signal-cli not connecting:** +```bash +# Check if daemon is running +curl http://localhost:8080/api/v1/check + +# Check signal-cli status +systemctl status signal-cli +``` + +**Permission errors:** +- Ensure your account is admin of the groups you want to manage +- signal-cli can only remove members from groups where you have admin privileges + +**Rate limiting:** +- Signal may rate-limit if you remove too many people too fast +- The script handles this automatically with retries + +**Groups not showing:** +- Run `signal-cli -a +1234567890 listGroups` to verify groups are synced +- You may need to link your device first to sync existing groups + +## Security Notes + +- The honeypot group should look legitimate enough that spammers join but obvious enough that real users don't +- The `banned.json` file contains phone numbers - protect this file +- signal-cli registration requires a real phone number for SMS verification +- Running as a linked device means your primary device must stay online diff --git a/RUN.md b/RUN.md new file mode 100644 index 0000000..0ca3bce --- /dev/null +++ b/RUN.md @@ -0,0 +1,12 @@ +## How to use + +# 1. Initial config +# Signal config: +docker run -it -v $PWD/signal-cli-data:/app/signal-cli-data /opt/signal-cli/bin/signal-cli +# spam_remover.py config: +docker run -it -v $PWD/signal-cli-data:/app/signal-cli-data /app/spam_remover.py + + + +# 2. Run when configured +docker run -v $PWD/config.json:/app/config.json -v $PWD/env:/app/env -v $PWD/signal-cli-data:/app/signal-cli-data -d diff --git a/config.json b/config.json new file mode 100644 index 0000000..bf7c2e1 --- /dev/null +++ b/config.json @@ -0,0 +1,9 @@ +{ + "signal_cli_endpoint": "http://localhost:8080", + "account": "+YOUR_PHONE_NUMBER", + "honeypot_group_id": "PASTE_BASE64_GROUP_ID_HERE", + "notify_self_number": "+YOUR_PHONE_NUMBER", + "excluded_groups": [], + "banned_list_path": "banned.json", + "log_file": "spam_remover.log" +} diff --git a/env b/env new file mode 100644 index 0000000..f6f63a6 --- /dev/null +++ b/env @@ -0,0 +1,3 @@ +SIGNAL_NUMBER=+44.. +SIGNAL_HOST=localhost +SIGNAL_PORT=8080 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8f54a36 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pysignalclijsonrpc>=25.9.0 +sseclient-py>=1.8.0 +requests>=2.31.0 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..406965a --- /dev/null +++ b/run.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +. /app/env + + +/opt/signal-cli/bin/signal-cli -a $SIGNAL_NUMBER daemon --http=$SIGNAL_HOST:$SIGNAL_PORT --config=/app/signal-cli-data & + +/app/spam_remover.py diff --git a/spam_remover.py b/spam_remover.py new file mode 100755 index 0000000..50a8c79 --- /dev/null +++ b/spam_remover.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +Signal Spammer Auto-Removal System + +Monitors a honeypot Signal group chat, detects spammers who message there, +and automatically removes them from all groups you admin. + +Usage: + python spam_remover.py # Daemon mode (SSE listener) + python spam_remover.py --ban NUM # Manually ban a number + python spam_remover.py --dry-run # Preview without changes + python spam_remover.py --once # Single check and exit +""" + +import argparse +import json +import logging +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +import requests +import sseclient +from pysignalclijsonrpc import SignalCliJSONRPCApi + + +class SpammerRemover: + def __init__(self, config_path: str = "config.json"): + self.config = self._load_config(config_path) + self.banned_list_path = Path(self.config["banned_list_path"]) + self.banned_list = self._load_banned_list() + self._setup_logging() + self.api = SignalCliJSONRPCApi( + endpoint=self.config["signal_cli_endpoint"], + account=self.config["account"], + ) + self.logger.info( + f"Initialized. Honeypot: {self.config['honeypot_group_id'][:12]}..." + ) + + def _load_config(self, config_path: str) -> dict[str, Any]: + path = Path(config_path) + if not path.exists(): + print(f"Error: Config file not found: {config_path}") + print("Create config.json from the template first.") + sys.exit(1) + with open(path) as f: + return json.load(f) + + def _setup_logging(self): + log_file = self.config.get("log_file", "spam_remover.log") + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler(log_file), + logging.StreamHandler(sys.stdout), + ], + ) + self.logger = logging.getLogger(__name__) + + def _load_banned_list(self) -> dict[str, Any]: + if self.banned_list_path.exists(): + with open(self.banned_list_path) as f: + return json.load(f) + return {} + + def _save_banned_list(self): + with open(self.banned_list_path, "w") as f: + json.dump(self.banned_list, f, indent=2) + + def is_banned(self, number: str) -> bool: + return number in self.banned_list + + def ban_number(self, number: str, reason: str = "honeypot_message"): + if self.is_banned(number): + self.logger.info(f"Already banned: {number}") + return + + self.banned_list[number] = { + "banned_at": datetime.now(timezone.utc).isoformat(), + "reason": reason, + "removed_from": [], + } + self._save_banned_list() + self.logger.info(f"Banned: {number}") + + def get_groups(self) -> list[dict[str, Any]]: + try: + return self.api.list_groups() + except Exception as e: + self.logger.error(f"Failed to list groups: {e}") + return [] + + def get_admin_groups(self) -> list[dict[str, Any]]: + groups = self.get_groups() + admin_groups = [] + for group in groups: + if not group.get("isMember", False): + continue + admins = group.get("admins", []) + members = group.get("members", []) + account = self.config["account"] + if account in admins or account in members: + if account in admins: + admin_groups.append(group) + return admin_groups + + def remove_from_group(self, group_id: str, group_name: str, number: str) -> bool: + try: + group = self.api.get_group(group_id) + members = group.get("members", []) + + member_numbers = [] + for m in members: + if isinstance(m, dict): + member_numbers.append(m.get("number", m.get("uuid", ""))) + else: + member_numbers.append(str(m)) + + if number not in member_numbers: + self.logger.info(f"{number} not in group {group_name}, skipping") + return True + + updated_members = [m for m in member_numbers if m != number] + + self.api.update_group( + name=group.get("name", ""), + members=updated_members, + admins=group.get("admins", []), + description=group.get("description", ""), + group_link=group.get("groupInviteLink", "disabled"), + ) + self.logger.info(f"Removed {number} from {group_name}") + return True + except Exception as e: + self.logger.error(f"Failed to remove from {group_name}: {e}") + return False + + def send_notification(self, message: str): + try: + self.api.send_message( + recipients=[self.config["notify_self_number"]], + message=message, + ) + self.logger.info(f"Notification sent: {message[:50]}...") + except Exception as e: + self.logger.error(f"Failed to send notification: {e}") + + def process_spammer(self, number: str, dry_run: bool = False) -> dict[str, Any]: + result = { + "number": number, + "already_banned": self.is_banned(number), + "groups_removed": [], + "groups_failed": [], + "dry_run": dry_run, + } + + if self.is_banned(number): + self.logger.info(f"Already banned: {number}") + return result + + if dry_run: + self.logger.info(f"[DRY RUN] Would ban: {number}") + admin_groups = self.get_admin_groups() + for group in admin_groups: + gid = group.get("id", "") + gname = group.get("name", "Unknown") + if gid == self.config["honeypot_group_id"]: + continue + if gid in self.config.get("excluded_groups", []): + continue + result["groups_removed"].append(gname) + return result + + self.ban_number(number) + + admin_groups = self.get_admin_groups() + removed_count = 0 + + for group in admin_groups: + gid = group.get("id", "") + gname = group.get("name", "Unknown") + + if gid == self.config["honeypot_group_id"]: + continue + if gid in self.config.get("excluded_groups", []): + continue + + if self.remove_from_group(gid, gname, number): + result["groups_removed"].append(gname) + removed_count += 1 + else: + result["groups_failed"].append(gname) + + if removed_count > 0: + self.banned_list[number]["removed_from"] = result["groups_removed"] + self._save_banned_list() + + notification = ( + f"Spammer removed: {number}\n" + f"Removed from {removed_count} group(s): {', '.join(result['groups_removed'])}" + ) + self.send_notification(notification) + + if result["groups_failed"]: + self.logger.warning( + f"Failed to remove from {len(result['groups_failed'])} groups" + ) + + return result + + def handle_message(self, envelope: dict): + source = envelope.get("sourceNumber") or envelope.get("source", "") + if not source: + return + + group_info = envelope.get("groupInfo", {}) + group_id = group_info.get("groupId", "") + + if group_id != self.config["honeypot_group_id"]: + return + + if self.is_banned(source): + return + + self.logger.info(f"Honeypot message from: {source}") + result = self.process_spammer(source) + self.logger.info( + f"Processed {source}: removed from {len(result['groups_removed'])} groups" + ) + + def daemon_mode(self): + self.logger.info("Starting daemon mode (SSE listener)...") + endpoint = self.config["signal_cli_endpoint"] + url = f"{endpoint}/api/v1/events" + + while True: + try: + self.logger.info(f"Connecting to SSE: {url}") + response = requests.get(url, stream=True, timeout=60) + response.raise_for_status() + + client = sseclient.SSEClient(response) + for event in client.events(): + if event.event == "message": + try: + data = json.loads(event.data) + envelope = data.get("envelope", {}) + self.handle_message(envelope) + except json.JSONDecodeError: + self.logger.warning(f"Invalid JSON: {event.data[:100]}") + except Exception as e: + self.logger.error(f"Error processing event: {e}") + + except requests.exceptions.ConnectionError: + self.logger.warning("Connection lost. Reconnecting in 5s...") + time.sleep(5) + except requests.exceptions.Timeout: + self.logger.debug("SSE timeout, reconnecting...") + except Exception as e: + self.logger.error(f"SSE error: {e}. Reconnecting in 5s...") + time.sleep(5) + + def once_mode(self): + self.logger.info("Running single check...") + admin_groups = self.get_admin_groups() + self.logger.info(f"Found {len(admin_groups)} admin'd groups") + + for group in admin_groups: + gid = group.get("id", "") + gname = group.get("name", "Unknown") + members = group.get("members", []) + + member_count = len(members) if isinstance(members, list) else 0 + self.logger.info(f" {gname}: {member_count} members") + + def manual_mode(self, number: str): + self.logger.info(f"Manual ban: {number}") + result = self.process_spammer(number) + + if result["already_banned"]: + print(f"Already banned: {number}") + else: + print(f"Banned: {number}") + if result["groups_removed"]: + print(f"Removed from: {', '.join(result['groups_removed'])}") + if result["groups_failed"]: + print(f"Failed to remove from: {', '.join(result['groups_failed'])}") + + def list_banned(self): + if not self.banned_list: + print("No banned numbers.") + return + + print(f"Banned numbers ({len(self.banned_list)}):") + for number, info in self.banned_list.items(): + banned_at = info.get("banned_at", "unknown") + removed = info.get("removed_from", []) + print(f" {number} (banned {banned_at}) - removed from {len(removed)} groups") + + +def main(): + parser = argparse.ArgumentParser( + description="Signal Spammer Auto-Removal System" + ) + parser.add_argument( + "--config", + default="config.json", + help="Path to config file (default: config.json)", + ) + parser.add_argument( + "--ban", + metavar="NUMBER", + help="Manually ban a phone number", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview what would be done without making changes", + ) + parser.add_argument( + "--once", + action="store_true", + help="Single check and exit", + ) + parser.add_argument( + "--list-banned", + action="store_true", + help="List all banned numbers", + ) + + args = parser.parse_args() + remover = SpammerRemover(config_path=args.config) + + if args.list_banned: + remover.list_banned() + elif args.ban: + remover.manual_mode(args.ban) + elif args.once: + remover.once_mode() + else: + remover.daemon_mode() + + +if __name__ == "__main__": + main()