commit 23348e83496e99df173f07f67a5502e6db0f15f9 Author: Joey Date: Thu Aug 6 12:23:37 2026 +0000 Initial commit 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()