commit a1045614c9c1908ef0dcccbec6e185e974e328d3 Author: Aljaz Ceru Date: Sun Aug 17 15:18:40 2025 +0200 working bot, cleaned up git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6e06394 --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# Signal configuration +SIGNAL_PHONE_NUMBER=+1234567890 + +# PrivateMode API configuration +PRIVATEMODE_BASE_URL=http://localhost:8080 +# Optional: Model to use (if not specified, will use first available model) +# PRIVATEMODE_MODEL=ibnzterrell/Meta-Llama-3.3-70B-Instruct-AWQ-INT4 + +# Permission System Configuration +# Path to permissions configuration file (default: permissions.json) +# PERMISSIONS_CONFIG_PATH=permissions.json + +# Signal Service Configuration +# Signal CLI REST API service endpoint (default: localhost:8080) +# SIGNAL_SERVICE=localhost:8080 + +# Security Configuration +# Enable/disable permission system (default: enabled) +# PERMISSIONS_ENABLED=true + +# Logging Configuration +# Log level for permission system (default: INFO) +# PERMISSIONS_LOG_LEVEL=INFO + +# Admin Configuration +# Default admin phone number for initial setup +# Set this to your phone number to get admin access on first run +# ADMIN_PHONE_NUMBER=+1234567890 + +# Whisper ASR Configuration +# Comma-separated list of Whisper ASR instance URLs (supports failover) +# Examples for different setups: +# - Host machine: http://localhost:9000,http://localhost:9001 +# - Docker container (bridge network): http://172.17.0.1:9000 +# - Docker container (host network): http://localhost:9000 +# - External server: http://whisper.example.com:9000 +# Multiple instances for failover: http://localhost:9000,http://localhost:9001,http://backup-whisper:9000 +WHISPER_ASR_URLS=http://localhost:9000 +# Optional: Whisper output format (text, json, vtt, srt, tsv) +WHISPER_OUTPUT_FORMAT=text +# Optional: Enable voice activity detection filter +WHISPER_VAD_FILTER=true +# Optional: Language for transcription (auto-detect if not specified) +# WHISPER_LANGUAGE=en + +# Voice Activation Configuration +# Phrase that triggers AI chat from voice messages (default: "hey jarvis") +# Examples: "hey computer", "assistant", "ai", "bot" +VOICE_ACTIVATION_PHRASE=hey jarvis \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e3f39ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +WORKDIR /app +COPY permissions.json . +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY signal_bot.py . + +CMD ["python", "signal_bot.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc544f6 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Shifra - Signal enabled confidential AI. + +A Signal messenger bot that gives you AI agent in your chat. + +## Features + +- Chat with AI assistant via Signal messages +- Voice message transcription with Whisper ASR +- Multi-instance Whisper support with automatic failover +- Maintains conversation context +- Simple command interface +- Docker deployment ready + +## Setup + +1. Copy `.env.example` to `.env` and configure: + ```bash + cp .env.example .env + ``` + +2. Edit `.env` with your configuration: + - `SIGNAL_PHONE_NUMBER`: Your Signal phone number (e.g., +1234567890) + - `PRIVATEMODE_BASE_URL`: URL of PrivateMode.ai API (default: http://localhost:8080) + - `PRIVATEMODE_MODEL`: Optional - specific model to use (if not set, uses first available) + - `WHISPER_ASR_URLS`: Comma-separated list of Whisper ASR instances (e.g., http://localhost:9000,http://localhost:9001) + - `WHISPER_OUTPUT_FORMAT`: Output format for transcription (default: text) + - `WHISPER_VAD_FILTER`: Enable voice activity detection (default: true) + - `WHISPER_LANGUAGE`: Optional - language for transcription (auto-detect if not set) + +3. Link Signal account (first time only): + ```bash + docker compose run --rm signal-cli-rest-api signal-cli link -n "Signal Bot" + ``` + Follow the instructions to scan QR code with Signal app. + +4. Start the bot: + ```bash + docker compose up -d + ``` + +## Usage + +Send messages to the bot: + +- `!chat ` - Chat with AI assistant +- `!clear` - Clear conversation history +- `!models` - List available AI models +- `!help` - Show available commands +- Any message without command prefix is treated as chat +- **Voice messages** - Send or forward voice memos for automatic transcription + +## Available Models + +The bot can use any model available through PrivateMode.ai API. Use `!models` command to see available models. Example models: +- `ibnzterrell/Meta-Llama-3.3-70B-Instruct-AWQ-INT4` + +## Development + +Run locally: +```bash +pip install -r requirements.txt +python signal_bot.py +``` + +## Architecture + +- Uses `signalbot` library for Signal integration +- Connects to PrivateMode.ai Chat Completions API endpoint +- Integrates with Whisper ASR for voice transcription +- Maintains conversation context per sender (last 10 messages) +- Supports docker deployment with signal-cli-rest-api +- No authentication required (follows PrivateMode.ai approach) + +## Whisper ASR Integration + +The bot supports voice message transcription using Whisper ASR. The docker-compose configuration uses `network_mode: "host"` to ensure the Signal bot can access Whisper instances running on: + +- **Host machine**: Use `http://localhost:9000` or `http://127.0.0.1:9000` +- **Docker containers (bridge network)**: Use `http://172.17.0.1:9000` (Docker's default bridge gateway) +- **Docker containers (host network)**: Use `http://localhost:9000` +- **External servers**: Use the server's URL (e.g., `http://whisper.example.com:9000`) + +### Multiple Whisper Instances + +Configure multiple Whisper instances for automatic failover: +```bash +WHISPER_ASR_URLS=http://localhost:9000,http://localhost:9001,http://backup-server:9000 +``` + +If the first instance fails or is unavailable, the bot automatically tries the next one in the list. + +### Voice Message Processing + +1. User sends or forwards a voice message to the Signal chat +2. Bot detects the voice attachment +3. Audio is sent to the first available Whisper instance +4. Transcribed text is returned with a 📝 prefix +5. Transcription is stored in conversation history for context diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..543ba3c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + signal-bot: + build: . + env_file: .env + network_mode: "host" + environment: + - SIGNAL_SERVICE=127.0.0.1:18380 + - SIGNAL_PHONE_NUMBER=${SIGNAL_PHONE_NUMBER} + - PRIVATEMODE_BASE_URL=${PRIVATEMODE_BASE_URL} + - PRIVATEMODE_MODEL=${PRIVATEMODE_MODEL} + - WHISPER_ASR_URLS=${WHISPER_ASR_URLS} + - WHISPER_OUTPUT_FORMAT=${WHISPER_OUTPUT_FORMAT:-text} + - WHISPER_VAD_FILTER=${WHISPER_VAD_FILTER:-true} + - WHISPER_LANGUAGE=${WHISPER_LANGUAGE:-} + volumes: + - ./.env:/app/.env + restart: unless-stopped + + privatemode-proxy: + image: ghcr.io/edgelesssys/privatemode/privatemode-proxy:latest + environment: + - PRIVATEMODE_API_KEY=${PRIVATEMODE_API_KEY:-} + - PRIVATEMODE_CACHE_MODE=${PRIVATEMODE_CACHE_MODE:-none} + - PRIVATEMODE_CACHE_SALT=${PRIVATEMODE_CACHE_SALT:-} + entrypoint: ["/bin/privatemode-proxy"] + command: [ + "--apiKey=${PRIVATEMODE_API_KEY}", + "--port=8080" + ] + ports: + - "28082:8080" + restart: unless-stopped + + diff --git a/example_permissions.json b/example_permissions.json new file mode 100644 index 0000000..6355866 --- /dev/null +++ b/example_permissions.json @@ -0,0 +1,24 @@ +{ + "roles": { + "admin": { + "description": "Full access to all commands and user management", + "permissions": ["*"] + }, + "user": { + "description": "Standard user with chat access", + "permissions": ["chat", "clear", "models", "help"] + }, + "guest": { + "description": "Limited access user", + "permissions": ["help"] + } + }, + "users": { + "+123456789": { + "role": "admin", + "name": "Default Admin", + "added_by": "system", + "added_at": "2024-01-01T00:00:00Z" + } + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a408cd7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +signalbot==0.8.0 +aiohttp==3.10.11 +python-dotenv==1.0.1 +pyyaml==6.0.2 +watchdog==3.0.0 \ No newline at end of file diff --git a/signal_bot.py b/signal_bot.py new file mode 100644 index 0000000..b19c24d --- /dev/null +++ b/signal_bot.py @@ -0,0 +1,1006 @@ +#!/usr/bin/env python3 +import os +import asyncio +import logging +import aiohttp +import json +import time +import base64 +import re +from datetime import datetime, timezone +from typing import Dict, List, Optional +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler +from signalbot import SignalBot, Command, Context +from dotenv import load_dotenv + +load_dotenv() + +# Configuration +VOICE_ACTIVATION_PHRASE = os.getenv('VOICE_ACTIVATION_PHRASE', 'hey jarvis').lower().strip() + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Valid bot commands +VALID_COMMANDS = { + "!clear", + "!models", + "!help", + "!adduser", + "!removeuser", + "!listusers", + "!listroles" +} +class PermissionFileHandler(FileSystemEventHandler): + """File system event handler for permissions.json changes""" + + def __init__(self, permission_manager): + self.permission_manager = permission_manager + + def on_modified(self, event): + if not event.is_directory and event.src_path.endswith('permissions.json'): + logger.info("Permissions file changed, reloading...") + self.permission_manager.reload_config() + + +class PermissionManager: + """Manages user permissions and authorization""" + + def __init__(self, config_path: str = "permissions.json", enable_file_watcher: bool = True): + self.config_path = config_path + self.config: Dict = {} + self.observer: Optional[Observer] = None + self.load_config() + if enable_file_watcher: + self.start_file_watcher() + else: + logger.info("File watcher disabled for debugging") + + def load_config(self) -> None: + """Load permissions configuration from JSON file""" + import os.path + + try: + # Read config file with retry mechanism to avoid race conditions + max_retries = 3 + for attempt in range(max_retries): + try: + with open(self.config_path, 'r') as f: + file_content = f.read() + break + except IOError as io_e: + if attempt < max_retries - 1: + logger.warning(f"Permissions file read attempt {attempt + 1} failed: {io_e}, retrying...") + time.sleep(0.1) + continue + raise + + # Parse JSON configuration + self.config = json.loads(file_content) + logger.info(f"Loaded permissions config: {len(self.config.get('users', {}))} users, {len(self.config.get('roles', {}))} roles") + + except FileNotFoundError: + logger.error(f"Permissions file {self.config_path} not found") + self.config = {"roles": {}, "users": {}} + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in permissions file: {e}") + logger.error(f"JSON error at line {e.lineno}, column {e.colno}, position {e.pos}") + + # Try to show the problematic part of the file + try: + with open(self.config_path, 'r') as f: + file_content = f.read() + + # Show context around the error + error_pos = e.pos if e.pos is not None else 0 + start = max(0, error_pos - 50) + end = min(len(file_content), error_pos + 50) + context = file_content[start:end] + + logger.error(f"File content around error (pos {error_pos}): {repr(context)}") + logger.error(f"Error character: {repr(file_content[error_pos:error_pos+1]) if error_pos < len(file_content) else 'EOF'}") + + except Exception as debug_e: + logger.error(f"Could not read file for debugging: {debug_e}") + + self.config = {"roles": {}, "users": {}} + + def reload_config(self) -> None: + """Reload configuration (called by file watcher)""" + self.load_config() + + def save_config(self) -> None: + """Save current configuration to file""" + try: + with open(self.config_path, 'w') as f: + json.dump(self.config, f, indent=2) + logger.info("Permissions configuration saved") + except Exception as e: + logger.error(f"Failed to save permissions config: {e}") + + def start_file_watcher(self) -> None: + """Start watching the permissions file for changes""" + try: + self.observer = Observer() + event_handler = PermissionFileHandler(self) + watch_dir = os.path.dirname(os.path.abspath(self.config_path)) or "." + self.observer.schedule(event_handler, watch_dir, recursive=False) + self.observer.start() + logger.info(f"Started file watcher for {self.config_path}") + except Exception as e: + logger.error(f"Failed to start file watcher: {e}") + + def stop_file_watcher(self) -> None: + """Stop the file watcher""" + if self.observer: + self.observer.stop() + self.observer.join() + + def is_user_authorized(self, phone_number: str) -> bool: + """Check if user is authorized to use the bot""" + users = self.config.get("users", {}) + return phone_number in users + + def get_user_role(self, phone_number: str) -> Optional[str]: + """Get user's role""" + user = self.config.get("users", {}).get(phone_number) + return user.get("role") if user else None + + def get_role_permissions(self, role: str) -> List[str]: + """Get permissions for a role""" + role_data = self.config.get("roles", {}).get(role, {}) + return role_data.get("permissions", []) + + def user_has_permission(self, phone_number: str, permission: str) -> bool: + """Check if user has specific permission""" + if not self.is_user_authorized(phone_number): + return False + + role = self.get_user_role(phone_number) + if not role: + return False + + permissions = self.get_role_permissions(role) + + # Check for wildcard permission + if "*" in permissions: + return True + + # Check for specific permission + return permission in permissions + + def add_user(self, phone_number: str, role: str, name: str = None, added_by: str = "admin") -> bool: + """Add a new user""" + if role not in self.config.get("roles", {}): + logger.error(f"Role '{role}' does not exist") + return False + + user_data = { + "role": role, + "name": name or f"User {phone_number}", + "added_by": added_by, + "added_at": datetime.now(timezone.utc).isoformat() + } + + self.config.setdefault("users", {})[phone_number] = user_data + self.save_config() + logger.info(f"Added user {phone_number} with role {role}") + return True + + def remove_user(self, phone_number: str) -> bool: + """Remove a user""" + if phone_number in self.config.get("users", {}): + del self.config["users"][phone_number] + self.save_config() + logger.info(f"Removed user {phone_number}") + return True + return False + + def list_users(self, role_filter: str = None) -> Dict[str, Dict]: + """List all users, optionally filtered by role""" + users = self.config.get("users", {}) + if role_filter: + return {phone: data for phone, data in users.items() + if data.get("role") == role_filter} + return users + + def list_roles(self) -> Dict[str, Dict]: + """List all available roles""" + return self.config.get("roles", {}) + + def log_unauthorized_access(self, phone_number: str, command: str = None, message: str = None) -> None: + """Log unauthorized access attempt""" + log_data = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "phone_number": phone_number, + "command": command, + "message_preview": message[:50] + "..." if message and len(message) > 50 else message, + "action": "unauthorized_access" + } + logger.warning(f"Unauthorized access attempt: {json.dumps(log_data)}") + + +def requires_permission(permission: str): + """Decorator to check permissions before executing command""" + def decorator(command_class): + original_handle = command_class.handle + + async def wrapped_handle(self, c: Context): + sender = c.message.source + permission_manager = getattr(self, 'permission_manager', None) + + if not permission_manager: + logger.error("No permission manager found in command") + return + + if not permission_manager.user_has_permission(sender, permission): + # Log unauthorized access + permission_manager.log_unauthorized_access( + sender, + permission, + c.message.text + ) + return # Silently ignore unauthorized access + + # User is authorized, proceed with command + await original_handle(self, c) + + command_class.handle = wrapped_handle + return command_class + + return decorator + + +class PrivateModeClient: + def __init__(self, base_url: str): + self.base_url = base_url.rstrip('/') + self.headers = { + "Content-Type": "application/json" + } + + async def list_models(self) -> list: + url = f"{self.base_url}/v1/models" + + async with aiohttp.ClientSession() as session: + try: + async with session.get(url, headers=self.headers) as response: + if response.status == 200: + data = await response.json() + return [model['id'] for model in data.get('data', [])] + else: + logger.error(f"Failed to list models: {response.status}") + return [] + except Exception as e: + logger.error(f"Model listing failed: {str(e)}") + return [] + + async def chat_completion(self, messages: list, model: str = None) -> str: + url = f"{self.base_url}/v1/chat/completions" + + # Use provided model or get the first available one + if not model: + available_models = await self.list_models() + if available_models: + model = available_models[0] + logger.info(f"Using model: {model}") + else: + return "Sorry, no models are available at the moment." + + payload = { + "model": model, + "messages": messages, + "temperature": 0.7, + "max_tokens": 1000 + } + + async with aiohttp.ClientSession() as session: + try: + async with session.post(url, json=payload, headers=self.headers) as response: + if response.status == 200: + data = await response.json() + return data['choices'][0]['message']['content'] + else: + error_text = await response.text() + logger.error(f"API error: {response.status} - {error_text}") + return f"Sorry, I encountered an error: {response.status}" + except Exception as e: + logger.error(f"Request failed: {str(e)}") + return f"Sorry, I couldn't process your request: {str(e)}" + + +class WhisperClient: + """Client for Whisper ASR service with failover support""" + + def __init__(self, whisper_urls: str, output_format: str = "text", + vad_filter: bool = True, language: Optional[str] = None): + """ + Initialize Whisper client with multiple instance support + + Args: + whisper_urls: Comma-separated list of Whisper ASR URLs + output_format: Output format (text, json, vtt, srt, tsv) + vad_filter: Enable voice activity detection filter + language: Language for transcription (auto-detect if None) + """ + self.urls = [url.strip() for url in whisper_urls.split(',')] + self.output_format = output_format + self.vad_filter = vad_filter + self.language = language if language else None + logger.info(f"Initialized WhisperClient with {len(self.urls)} instances") + + async def transcribe_audio(self, audio_data: bytes) -> Optional[str]: + """ + Transcribe audio data with failover support + + Args: + audio_data: Audio file data in bytes + + Returns: + Transcribed text or None if all instances fail + """ + # Try each Whisper instance in order + for url in self.urls: + try: + result = await self._transcribe_with_instance(url, audio_data) + if result: + return result + except Exception as e: + logger.warning(f"Whisper instance {url} failed: {str(e)}") + continue + + logger.error("All Whisper instances failed") + return None + + async def _transcribe_with_instance(self, base_url: str, audio_data: bytes) -> Optional[str]: + """ + Transcribe audio using a specific Whisper instance + + Args: + base_url: Base URL of the Whisper instance + audio_data: Audio file data in bytes + + Returns: + Transcribed text or None if failed + """ + url = f"{base_url.rstrip('/')}/asr" + + # Prepare form data + data = aiohttp.FormData() + data.add_field('audio_file', + audio_data, + filename='audio.ogg', + content_type='audio/ogg') + + # Build query parameters + params = { + 'output': self.output_format, + 'task': 'transcribe', + 'vad_filter': str(self.vad_filter).lower() + } + + if self.language: + params['language'] = self.language + + timeout = aiohttp.ClientTimeout(total=60) # 60 second timeout for transcription + + async with aiohttp.ClientSession(timeout=timeout) as session: + try: + async with session.post(url, data=data, params=params) as response: + if response.status == 200: + if self.output_format == 'json': + result = await response.json() + # Extract text from JSON response + if isinstance(result, dict) and 'text' in result: + return result['text'].strip() + elif isinstance(result, dict) and 'segments' in result: + # Combine all segments + segments = result['segments'] + text = ' '.join(seg.get('text', '').strip() for seg in segments) + return text.strip() + else: + # Plain text response + text = await response.text() + return text.strip() + else: + error_text = await response.text() + logger.error(f"Whisper API error from {base_url}: {response.status} - {error_text}") + return None + except asyncio.TimeoutError: + logger.error(f"Whisper request to {base_url} timed out") + return None + except Exception as e: + logger.error(f"Whisper request to {base_url} failed: {str(e)}") + return None + + +@requires_permission("chat") +class ChatCommand(Command): + def __init__(self, privatemode_client: PrivateModeClient, permission_manager: PermissionManager, model: str = None): + self.privatemode_client = privatemode_client + self.permission_manager = permission_manager + self.model = model + self.conversations = {} + + def describe(self) -> str: + return "Chat with AI assistant" + + async def handle(self, c: Context): + logger.info(f"ChatCommand.handle called with message: {c.message.text}") + + message_text = c.message.text + if not message_text: + await c.send("Please provide a message to chat with the AI.") + return + + # Get or create conversation history for this sender + sender = c.message.source + if sender not in self.conversations: + self.conversations[sender] = [] + + # Add user message to history + self.conversations[sender].append({ + "role": "user", + "content": message_text + }) + + # Keep only last 10 messages for context + if len(self.conversations[sender]) > 10: + self.conversations[sender] = self.conversations[sender][-10:] + + # Get AI response + response = await self.privatemode_client.chat_completion(self.conversations[sender], self.model) + + # Add assistant response to history+50672831532 + self.conversations[sender].append({ + "role": "assistant", + "content": response + }) + + # Send response + await c.send(response) + + +@requires_permission("chat") +class ClearCommand(Command): + def __init__(self, chat_command: ChatCommand, permission_manager: PermissionManager): + self.chat_command = chat_command + self.permission_manager = permission_manager + + def describe(self) -> str: + return "Clear conversation history" + + async def handle(self, c: Context): + sender = c.message.source + if sender in self.chat_command.conversations: + del self.chat_command.conversations[sender] + await c.send("Conversation history cleared.") + else: + await c.send("No conversation history to clear.") + + +@requires_permission("models") +class ModelsCommand(Command): + def __init__(self, privatemode_client: PrivateModeClient, permission_manager: PermissionManager): + self.privatemode_client = privatemode_client + self.permission_manager = permission_manager + + def describe(self) -> str: + return "List available AI models" + + async def handle(self, c: Context): + models = await self.privatemode_client.list_models() + if models: + models_text = "Available models:\n" + "\n".join(f"• {model}" for model in models) + else: + models_text = "No models available or unable to fetch model list." + await c.send(models_text) + + +@requires_permission("help") +class HelpCommand(Command): + def __init__(self, permission_manager: PermissionManager): + self.permission_manager = permission_manager + + def describe(self) -> str: + return "Show available commands" + + async def handle(self, c: Context): + help_text = """Available commands: +!clear - Clear conversation history +!models - List available models +!help - Show this help message + +Admin commands (admin only): +!adduser [name] - Add a new user +!removeuser - Remove a user +!listusers [role] - List users (optionally filtered by role) +!listroles - List available roles + +Features: +• Send text messages for AI chat +• Send voice messages for automatic transcription +• Forward voice messages from other chats for transcription + +You can also send messages without commands for direct chat.""" + await c.send(help_text) + + +@requires_permission("admin") +class AddUserCommand(Command): + def __init__(self, permission_manager: PermissionManager): + self.permission_manager = permission_manager + + def describe(self) -> str: + return "Add a new user (admin only)" + + async def handle(self, c: Context): + # Parse command arguments + parts = c.message.text.strip().split() + if len(parts) < 3: + await c.send("Usage: !adduser [name]") + return + + phone = parts[1] + role = parts[2] + name = " ".join(parts[3:]) if len(parts) > 3 else None + + # Validate phone number format + if not phone.startswith('+'): + await c.send("Phone number must start with + (e.g., +1234567890)") + return + + # Add user + added_by = c.message.source + if self.permission_manager.add_user(phone, role, name, added_by): + user_name = name or f"User {phone}" + await c.send(f"✅ Added user {user_name} ({phone}) with role '{role}'") + else: + await c.send(f"❌ Failed to add user. Role '{role}' may not exist.") + + +@requires_permission("admin") +class RemoveUserCommand(Command): + def __init__(self, permission_manager: PermissionManager): + self.permission_manager = permission_manager + + def describe(self) -> str: + return "Remove a user (admin only)" + + async def handle(self, c: Context): + # Parse command arguments + parts = c.message.text.strip().split() + if len(parts) != 2: + await c.send("Usage: !removeuser ") + return + + phone = parts[1] + + # Check if user exists + users = self.permission_manager.list_users() + if phone not in users: + await c.send(f"❌ User {phone} not found") + return + + # Prevent removing yourself + if phone == c.message.source: + await c.send("❌ You cannot remove yourself") + return + + # Remove user + user_name = users[phone].get("name", phone) + if self.permission_manager.remove_user(phone): + await c.send(f"✅ Removed user {user_name} ({phone})") + else: + await c.send(f"❌ Failed to remove user {phone}") + + +@requires_permission("admin") +class ListUsersCommand(Command): + def __init__(self, permission_manager: PermissionManager): + self.permission_manager = permission_manager + + def describe(self) -> str: + return "List users (admin only)" + + async def handle(self, c: Context): + # Parse optional role filter + parts = c.message.text.strip().split() + role_filter = parts[1] if len(parts) > 1 else None + + users = self.permission_manager.list_users(role_filter) + + if not users: + filter_text = f" with role '{role_filter}'" if role_filter else "" + await c.send(f"No users found{filter_text}") + return + + # Format user list + role_text = f" with role '{role_filter}'" if role_filter else "" + header = f"Users{role_text}:\n" + user_lines = [] + + for phone, data in users.items(): + name = data.get("name", "Unknown") + role = data.get("role", "unknown") + added_at = data.get("added_at", "unknown")[:10] # Just the date part + user_lines.append(f"• {name} ({phone}) - {role} - added {added_at}") + + message = header + "\n".join(user_lines) + await c.send(message) + + +@requires_permission("admin") +class ListRolesCommand(Command): + def __init__(self, permission_manager: PermissionManager): + self.permission_manager = permission_manager + + def describe(self) -> str: + return "List available roles (admin only)" + + async def handle(self, c: Context): + roles = self.permission_manager.list_roles() + + if not roles: + await c.send("No roles configured") + return + + # Format roles list + role_lines = [] + for role_name, role_data in roles.items(): + description = role_data.get("description", "No description") + permissions = role_data.get("permissions", []) + perm_text = ", ".join(permissions) + role_lines.append(f"• **{role_name}**: {description}\n Permissions: {perm_text}") + + message = "Available roles:\n\n" + "\n\n".join(role_lines) + await c.send(message) + + +def main(): + # Load configuration + signal_service = os.getenv("SIGNAL_SERVICE", "localhost:8080") + phone_number = os.getenv("SIGNAL_PHONE_NUMBER") + + if not phone_number: + logger.error("SIGNAL_PHONE_NUMBER environment variable is required") + return + + # PrivateMode API configuration + privatemode_base_url = os.getenv("PRIVATEMODE_BASE_URL", "http://localhost:8080") + model = os.getenv("PRIVATEMODE_MODEL", None) + + # Initialize PrivateMode client + privatemode_client = PrivateModeClient(privatemode_base_url) + + # Whisper ASR configuration + whisper_urls = os.getenv("WHISPER_ASR_URLS", None) + whisper_client = None + + if whisper_urls: + whisper_output_format = os.getenv("WHISPER_OUTPUT_FORMAT", "text") + whisper_vad_filter = os.getenv("WHISPER_VAD_FILTER", "true").lower() == "true" + whisper_language = os.getenv("WHISPER_LANGUAGE", None) + + # Initialize Whisper client + whisper_client = WhisperClient( + whisper_urls=whisper_urls, + output_format=whisper_output_format, + vad_filter=whisper_vad_filter, + language=whisper_language + ) + logger.info("Whisper ASR transcription enabled") + else: + logger.info("Whisper ASR not configured, voice transcription disabled") + + # Initialize Permission Manager (disable file watcher for debugging if needed) + enable_watcher = os.getenv("ENABLE_FILE_WATCHER", "true").lower() == "true" + permission_manager = PermissionManager(enable_file_watcher=enable_watcher) + + # Initialize Signal bot + bot = SignalBot({ + "signal_service": signal_service, + "phone_number": phone_number, + "logging_level": logging.INFO, + "download_attachments": True # Enable attachment downloading + }) + + # Create command instances + chat_command = ChatCommand(privatemode_client, permission_manager, model) + clear_command = ClearCommand(chat_command, permission_manager) + models_command = ModelsCommand(privatemode_client, permission_manager) + help_command = HelpCommand(permission_manager) + + # Create admin command instances + add_user_command = AddUserCommand(permission_manager) + remove_user_command = RemoveUserCommand(permission_manager) + list_users_command = ListUsersCommand(permission_manager) + list_roles_command = ListRolesCommand(permission_manager) + + # Single message handler that processes all messages + class MessageHandler(Command): + def __init__(self, whisper_client: Optional[WhisperClient] = None): + self.conversations = {} # Store conversations here instead + self.whisper_client = whisper_client + + def describe(self) -> str: + return "Main message handler" + + async def handle(self, c: Context): + sender = c.message.source + message_text = c.message.text + + # Check for voice messages (attachments) + has_base64_attachments = hasattr(c.message, 'base64_attachments') and c.message.base64_attachments + has_local_attachments = hasattr(c.message, 'attachments_local_filenames') and c.message.attachments_local_filenames + + # Extract attachment data from raw message if signalbot library fails + has_manual_attachments = False + if hasattr(c.message, 'raw_message') and isinstance(c.message.raw_message, dict): + if 'envelope' in c.message.raw_message and 'dataMessage' in c.message.raw_message['envelope']: + data_msg = c.message.raw_message['envelope']['dataMessage'] + if 'attachments' in data_msg: + raw_attachments = data_msg['attachments'] + for attachment in raw_attachments: + att_id = attachment.get('id') + if att_id: + if not hasattr(c.message, '_manual_attachments'): + c.message._manual_attachments = [] + c.message._manual_attachments.append({ + 'id': att_id, + 'contentType': attachment.get('contentType'), + 'size': attachment.get('size') + }) + has_manual_attachments = True + + # Process voice message if attachments found + if has_base64_attachments or has_local_attachments or has_manual_attachments: + await self.handle_voice_message(c) + return + + # Skip messages with no text content + if not message_text: + return + + logger.info(f"Received message from {sender}: {message_text}") + + # Check if message starts with ! (command) and is a valid command + if message_text.startswith('!') and message_text.split()[0].lower() in VALID_COMMANDS: + await self.handle_command(c, message_text) + else: + # Regular chat message - check chat permission + if not permission_manager.user_has_permission(sender, "chat"): + permission_manager.log_unauthorized_access(sender, "chat", message_text) + return + + await self.handle_chat(c, message_text) + + async def handle_command(self, c: Context, message_text: str): + """Handle command messages that start with !""" + sender = c.message.source + command_parts = message_text.strip().split() + command = command_parts[0].lower() + + # Route to appropriate command handler + if command == "!clear": + if not permission_manager.user_has_permission(sender, "chat"): + permission_manager.log_unauthorized_access(sender, "chat", message_text) + return + await clear_command.handle(c) + + elif command == "!models": + if not permission_manager.user_has_permission(sender, "models"): + permission_manager.log_unauthorized_access(sender, "models", message_text) + return + await models_command.handle(c) + + elif command == "!help": + if not permission_manager.user_has_permission(sender, "help"): + permission_manager.log_unauthorized_access(sender, "help", message_text) + return + await help_command.handle(c) + + elif command == "!adduser": + if not permission_manager.user_has_permission(sender, "admin"): + permission_manager.log_unauthorized_access(sender, "admin", message_text) + return + await add_user_command.handle(c) + + elif command == "!removeuser": + if not permission_manager.user_has_permission(sender, "admin"): + permission_manager.log_unauthorized_access(sender, "admin", message_text) + return + await remove_user_command.handle(c) + + elif command == "!listusers": + if not permission_manager.user_has_permission(sender, "admin"): + permission_manager.log_unauthorized_access(sender, "admin", message_text) + return + await list_users_command.handle(c) + + elif command == "!listroles": + if not permission_manager.user_has_permission(sender, "admin"): + permission_manager.log_unauthorized_access(sender, "admin", message_text) + return + await list_roles_command.handle(c) + + else: + # Unknown command - silently ignore + return + + async def handle_chat(self, c: Context, message_text: str): + """Handle regular chat messages (non-commands)""" + sender = c.message.source + + # Get or create conversation history for this sender + if sender not in self.conversations: + self.conversations[sender] = [] + + # Add user message to history + self.conversations[sender].append({ + "role": "user", + "content": message_text + }) + + # Keep only last 10 messages for context + if len(self.conversations[sender]) > 10: + self.conversations[sender] = self.conversations[sender][-10:] + + # Get AI response + response = await privatemode_client.chat_completion(self.conversations[sender], model) + + # Add assistant response to history + self.conversations[sender].append({ + "role": "assistant", + "content": response + }) + + # Send response + await c.send(response) + + async def handle_voice_message(self, c: Context): + """Handle voice messages by transcribing them""" + sender = c.message.source + + # Check if user has chat permission + if not permission_manager.user_has_permission(sender, "chat"): + permission_manager.log_unauthorized_access(sender, "voice_message", "Voice message") + return + + # Check if Whisper client is configured + if not self.whisper_client: + await c.send("Voice transcription is not configured. Please contact the administrator.") + return + + logger.info(f"Processing voice message from {sender}") + + try: + audio_data = None + + # Try base64 attachments first (preferred method) + if hasattr(c.message, 'base64_attachments') and c.message.base64_attachments: + audio_data = base64.b64decode(c.message.base64_attachments[0]) + + # Try local attachment files + elif hasattr(c.message, 'attachments_local_filenames') and c.message.attachments_local_filenames: + local_filename = c.message.attachments_local_filenames[0] + try: + with open(local_filename, 'rb') as f: + audio_data = f.read() + except Exception as e: + logger.error(f"Failed to read local attachment file {local_filename}: {e}") + await c.send("Unable to read voice message attachment.") + return + + # Download from signal-cli API directly (fallback method) + elif hasattr(c.message, '_manual_attachments') and c.message._manual_attachments: + attachment = c.message._manual_attachments[0] + att_id = attachment['id'] + + # Download attachment from signal-cli-rest-api + signal_service = os.getenv("SIGNAL_SERVICE", "127.0.0.1:18380") + phone_number = os.getenv("SIGNAL_PHONE_NUMBER") + attachment_url = f"http://{signal_service}/v1/attachments/{att_id}" + + async with aiohttp.ClientSession() as session: + try: + params = {"number": phone_number} + async with session.get(attachment_url, params=params) as response: + if response.status == 200: + audio_data = await response.read() + logger.info(f"Downloaded {len(audio_data)} bytes for voice transcription") + else: + error_text = await response.text() + logger.error(f"Failed to download attachment: {response.status} - {error_text}") + await c.send("Unable to download voice message attachment.") + return + except Exception as e: + logger.error(f"Error downloading attachment: {str(e)}") + await c.send("Error downloading voice message attachment.") + return + + if not audio_data: + logger.warning("No attachment data available") + await c.send("Voice message received but no audio data available.") + return + + # Transcribe the audio + transcription = await self.whisper_client.transcribe_audio(audio_data) + + if transcription: + # Check if this should trigger AI chat (starts with configured activation phrase) + should_chat = transcription.lower().strip().startswith(VOICE_ACTIVATION_PHRASE) + + if not should_chat: + # Just transcription request - send status and result + await c.send("Transcribing your message...") + await c.send(f"Transcription:\n{transcription}") + # If should_chat=True, we don't send any intermediate messages + + if should_chat: + # Remove activation phrase from the transcription and process as chat + activation_pattern = f'^{re.escape(VOICE_ACTIVATION_PHRASE)}[,\s]*' + chat_text = re.sub(activation_pattern, '', transcription, flags=re.IGNORECASE).strip() + + if chat_text: # Only proceed if there's text after activation phrase + # Store in conversation history + if sender not in self.conversations: + self.conversations[sender] = [] + + self.conversations[sender].append({ + "role": "user", + "content": chat_text + }) + + # Keep only last 10 messages for context + if len(self.conversations[sender]) > 10: + self.conversations[sender] = self.conversations[sender][-10:] + + # Get AI response + ai_response = await privatemode_client.chat_completion(self.conversations[sender], model) + + # Add assistant response to history + self.conversations[sender].append({ + "role": "assistant", + "content": ai_response + }) + + # Send AI response + await c.send(f"🤖 {ai_response}") + else: + await c.send("🤖 Yes? How can I help you?") + else: + await c.send("Sorry, I couldn't transcribe the voice message. Please try again or check if the audio is clear.") + + except Exception as e: + logger.error(f"Error processing voice message: {str(e)}") + await c.send("An error occurred while processing the voice message. Please try again.") + + # Register the single message handler + message_handler = MessageHandler(whisper_client=whisper_client) + + # Also update the clear command to use the main handler's conversations + clear_command.chat_command.conversations = message_handler.conversations + + bot.register(message_handler) + + logger.info(f"Starting Signal bot on {signal_service} with number {phone_number}") + logger.info(f"Using PrivateMode API at {privatemode_base_url}") + if model: + logger.info(f"Using model: {model}") + logger.info("Permission system enabled") + if whisper_client: + logger.info(f"Voice transcription enabled with {len(whisper_client.urls)} Whisper instance(s)") + logger.info(f"Whisper instances: {', '.join(whisper_client.urls)}") + + try: + bot.start() + except KeyboardInterrupt: + logger.info("Bot stopped by user") + finally: + # Clean up file watcher + permission_manager.stop_file_watcher() + + +if __name__ == "__main__": + main() \ No newline at end of file