diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c7e0c62
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+settings.py
+.venv/
+.env
+__pycache__/
+*.pyc
+*.pyo
+
diff --git a/example_settings.py b/example_settings.py
new file mode 100644
index 0000000..ae910a5
--- /dev/null
+++ b/example_settings.py
@@ -0,0 +1,4 @@
+SIGNAL_URL='http://localhost:8080', # Your signal-cli-rest-api port
+SIGNAL_NUBMER='+123456'
+OLLAMA_URL='http://localhost:11434',
+OLLAMA_MODEL='qwen3:4b' # Updated to match your available model
diff --git a/signallama.py b/signallama.py
new file mode 100644
index 0000000..bd97d69
--- /dev/null
+++ b/signallama.py
@@ -0,0 +1,287 @@
+import asyncio
+import json
+import logging
+import re
+import signal as py_signal
+import sqlite3
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+from settings import SIGNAL_URL, SIGNAL_NUBMER, OLLAMA_URL, OLLAMA_MODEL
+
+import aiohttp
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO, # Changed back to INFO now that we've debugged
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+)
+logger = logging.getLogger("SignalOllamaBridge")
+
+DB_FILE = Path(__file__).parent / "history.db"
+MAX_HISTORY = 10 # number of turns to keep per user
+
+
+def filter_think_tags(text: str) -> str:
+ """Remove content between tags from text"""
+ # Remove ... blocks (case insensitive, multiline)
+ filtered = re.sub(r'.*?', '', text, flags=re.IGNORECASE | re.DOTALL)
+ # Clean up extra whitespace
+ filtered = re.sub(r'\n\s*\n\s*\n', '\n\n', filtered) # Multiple newlines to double
+ return filtered.strip()
+
+
+def init_db(db_path: Path) -> None:
+ conn = sqlite3.connect(str(db_path))
+ c = conn.cursor()
+ c.execute(
+ '''
+ CREATE TABLE IF NOT EXISTS history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user TEXT NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT NOT NULL,
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ '''
+ )
+ conn.commit()
+ conn.close()
+
+
+class ContextManager:
+ def __init__(self, db_path: Path, max_history: int = MAX_HISTORY) -> None:
+ self.db_path = db_path
+ self.max_history = max_history
+
+ def add_message(self, user: str, role: str, content: str) -> None:
+ conn = sqlite3.connect(str(self.db_path))
+ c = conn.cursor()
+ c.execute(
+ 'INSERT INTO history (user, role, content) VALUES (?, ?, ?)',
+ (user, role, content)
+ )
+ conn.commit()
+ # prune old
+ c.execute(
+ '''
+ DELETE FROM history
+ WHERE id IN (
+ SELECT id FROM history
+ WHERE user = ?
+ ORDER BY timestamp DESC
+ LIMIT -1 OFFSET ?
+ )
+ ''', (user, self.max_history * 2)
+ )
+ conn.commit()
+ conn.close()
+
+ def get_history(self, user: str) -> List[Dict[str, str]]:
+ conn = sqlite3.connect(str(self.db_path))
+ c = conn.cursor()
+ c.execute(
+ 'SELECT role, content FROM history WHERE user = ? ORDER BY timestamp ASC',
+ (user,)
+ )
+ rows = c.fetchall()
+ conn.close()
+ return [{'role': row[0], 'content': row[1]} for row in rows]
+
+
+@dataclass
+class SignalConfig:
+ api_url: str
+ number: str
+ receive_timeout: int = 10 # seconds
+ poll_interval: float = 1.0 # seconds between polls
+
+
+@dataclass
+class OllamaConfig:
+ api_url: str
+ model: str
+
+
+class SignalOllamaBridge:
+ def __init__(
+ self,
+ signal_cfg: SignalConfig,
+ ollama_cfg: OllamaConfig,
+ context_mgr: ContextManager
+ ) -> None:
+ self.signal_cfg = signal_cfg
+ self.ollama_cfg = ollama_cfg
+ self.context = context_mgr
+ self.session: Optional[aiohttp.ClientSession] = None
+ self.running = True
+
+ async def start(self) -> None:
+ init_db(self.context.db_path)
+ self.session = aiohttp.ClientSession()
+ loop = asyncio.get_running_loop()
+ loop.add_signal_handler(py_signal.SIGINT, self._stop)
+ loop.add_signal_handler(py_signal.SIGTERM, self._stop)
+ logger.info("Bridge started using REST polling mode.")
+ await self._poll_loop()
+
+ async def _poll_loop(self) -> None:
+ # Don't URL encode the number - signal-cli-rest-api expects raw number
+ receive_url = f"{self.signal_cfg.api_url}/v1/receive/{self.signal_cfg.number}"
+ send_url = f"{self.signal_cfg.api_url}/v2/send"
+
+ while self.running:
+ try:
+ params = {
+ "timeout": self.signal_cfg.receive_timeout,
+ "ignore_attachments": "true",
+ "ignore_stories": "true"
+ }
+ async with self.session.get(receive_url, params=params) as resp:
+ resp.raise_for_status()
+ text = await resp.text()
+
+ # Parse response - it's a JSON array
+ if not text.strip():
+ await asyncio.sleep(self.signal_cfg.poll_interval)
+ continue
+
+ logger.debug("Raw response from signal API: %s", text[:500])
+
+ try:
+ # Response is a JSON array of messages
+ messages = json.loads(text)
+ if not isinstance(messages, list):
+ messages = [messages] # Handle single message case
+ logger.debug("Parsed %d messages from API", len(messages))
+ except json.JSONDecodeError as e:
+ logger.error("Failed to parse JSON response: %s", e)
+ logger.debug("Problematic response: %s", text[:200])
+ await asyncio.sleep(self.signal_cfg.poll_interval)
+ continue
+
+ except Exception as e:
+ logger.error("Error receiving messages: %s", e)
+ await asyncio.sleep(self.signal_cfg.poll_interval)
+ continue
+
+ for msg in messages:
+ try:
+ # Skip non-dict messages
+ if not isinstance(msg, dict):
+ logger.debug("Skipping non-dict message: %s", type(msg).__name__)
+ continue
+
+ # Extract envelope
+ envelope = msg.get('envelope', {})
+ if not envelope:
+ logger.debug("No envelope found, skipping message")
+ continue
+
+ # Only process messages with actual content (dataMessage)
+ data_message = envelope.get('dataMessage')
+ if not data_message:
+ # Skip typing indicators and other non-content messages
+ msg_type = 'typing' if envelope.get('typingMessage') else 'other'
+ logger.debug("Skipping %s message (no dataMessage)", msg_type)
+ continue
+
+ # Get sender info
+ author = (envelope.get('source') or
+ envelope.get('sourceNumber') or
+ envelope.get('sourceName'))
+
+ # Get message content
+ body = data_message.get('message', '').strip()
+
+ if not author or not body:
+ logger.debug("Missing author (%s) or body (%s), skipping", author, body)
+ continue
+
+ logger.info("Received from %s: %s", author, body)
+ reply = await self._get_ai_response(body, author)
+
+ payload = {
+ 'number': self.signal_cfg.number,
+ 'recipients': [author],
+ 'message': reply,
+ 'text_mode': 'normal'
+ }
+
+ try:
+ async with self.session.post(send_url, json=payload) as resp_send:
+ resp_send.raise_for_status()
+ response_data = await resp_send.json()
+ logger.info("Sent to %s (timestamp: %s)", author, response_data.get('timestamp', 'unknown'))
+ except Exception as e:
+ logger.error("Error sending to %s: %s", author, e)
+
+ except Exception as e:
+ logger.error("Error processing message: %s", e)
+ logger.debug("Problematic message: %s", str(msg)[:200] if 'msg' in locals() else 'N/A')
+
+ await asyncio.sleep(self.signal_cfg.poll_interval)
+
+ if self.session:
+ await self.session.close()
+
+ async def _get_ai_response(self, prompt: str, user: str) -> str:
+ url = f"{self.ollama_cfg.api_url}/api/generate"
+ history = self.context.get_history(user)
+
+ # Build context from history
+ context = ""
+ for msg in history:
+ role = "Human" if msg['role'] == 'user' else "Assistant"
+ context += f"{role}: {msg['content']}\n"
+ context += f"Human: {prompt}\nAssistant:"
+
+ payload = {
+ 'model': self.ollama_cfg.model,
+ 'prompt': context,
+ 'stream': False
+ }
+
+ try:
+ async with self.session.post(url, json=payload) as resp:
+ resp.raise_for_status()
+ data = await resp.json()
+
+ # Ollama generate API returns response in 'response' field
+ reply = data['response'].strip()
+
+ # Filter out content before saving and sending
+ filtered_reply = filter_think_tags(reply)
+
+ self.context.add_message(user, 'user', prompt)
+ self.context.add_message(user, 'assistant', filtered_reply)
+
+ logger.debug("Original reply length: %d, filtered length: %d", len(reply), len(filtered_reply))
+ return filtered_reply
+
+ except Exception as e:
+ logger.error("Ollama API error: %s", e)
+ return "Sorry, I encountered an error."
+
+ def _stop(self) -> None:
+ logger.info("Shutdown signal received.")
+ self.running = False
+
+
+async def main() -> None:
+ signal_cfg = SignalConfig(
+ api_url= SIGNAL_URL,
+ number= SIGNAL_NUBMER
+ )
+ ollama_cfg = OllamaConfig(
+ api_url= OLLAMA_URL,
+ model= OLLAMA_MODEL
+ )
+ context_mgr = ContextManager(DB_FILE)
+ bridge = SignalOllamaBridge(signal_cfg, ollama_cfg, context_mgr)
+ await bridge.start()
+
+
+if __name__ == '__main__':
+ asyncio.run(main())
\ No newline at end of file