Polls Evolution API every 30s for new messages from a contact. LLM chain: local (atlas-free/hermes3) → Groq 70B fallback. Guardrails: no secrets, no finance, no private data. Deploy: copy enis_monitor.py, install systemd unit, set state file.
170 lines
6.7 KiB
Python
Executable file
170 lines
6.7 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
Atlas Brain — Enis WhatsApp monitor + auto-reply
|
|
Polls Evolution API for new Enis messages, replies via local LLM with guardrails.
|
|
State tracked in /opt/atlas/brain/enis_state.json
|
|
"""
|
|
|
|
import json, os, time, requests, logging, re
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
STATE_FILE = "/opt/atlas/brain/enis_state.json"
|
|
LOG_FILE = "/opt/atlas/logs/enis_monitor.log"
|
|
EVO_URL = "http://localhost:8120"
|
|
EVO_KEY = "atlas-evo-key-2026"
|
|
ENIS_JID = "124674495234167@lid"
|
|
LLM_URL = "http://localhost:8888/v1/chat/completions"
|
|
POLL_SEC = 30
|
|
|
|
Path("/opt/atlas/brain").mkdir(parents=True, exist_ok=True)
|
|
Path("/opt/atlas/logs").mkdir(parents=True, exist_ok=True)
|
|
logging.basicConfig(
|
|
filename=LOG_FILE, level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s"
|
|
)
|
|
log = logging.getLogger("enis-monitor")
|
|
|
|
SYSTEM_PROMPT = """You are Atlas Brain — the AI core of Atlas Corporation, built by Chaib Aarab in Tilburg, NL.
|
|
You are talking to Enis, a friend and fellow AI/tech builder who is building his own second brain.
|
|
|
|
Your personality: knowledgeable, direct, friendly, Muslim-aware (use mashAllah/SubhanAllah naturally when fitting).
|
|
You speak as a peer builder, not as an assistant.
|
|
|
|
HARD GUARDRAILS — never break these:
|
|
1. NEVER share passwords, API keys, access tokens, internal IPs, or any credentials.
|
|
2. NEVER discuss Chaib's personal finances, bijstand, gemeente/municipality matters, or welfare benefits.
|
|
3. NEVER make financial promises or commitments on behalf of Atlas Corporation.
|
|
4. NEVER claim to be human or deny being an AI when sincerely asked.
|
|
5. NEVER discuss client data, Odoo leads, or confidential business details.
|
|
6. NEVER share the internal architecture (specific ports, container names, secret paths).
|
|
7. If asked to do something Chaib hasn't approved, say you'll flag it for him.
|
|
8. If Enis asks for the git repo: https://git.atlascorporation.nl/chaib/atlas-os (this is public, safe to share).
|
|
9. Keep replies concise — WhatsApp, not essays. 2-4 sentences max unless a technical question needs more.
|
|
10. You can discuss: AI/LLM tech, second brain architecture, Atlas OS, how you work (at a high level), Enis's projects.
|
|
|
|
If you are uncertain whether something is safe to share, say "I'll check with Chaib on that."
|
|
"""
|
|
|
|
def load_state():
|
|
if Path(STATE_FILE).exists():
|
|
return json.loads(Path(STATE_FILE).read_text())
|
|
return {"last_ts": 0, "last_id": ""}
|
|
|
|
def save_state(s):
|
|
Path(STATE_FILE).write_text(json.dumps(s))
|
|
|
|
def get_new_enis_messages(since_ts):
|
|
try:
|
|
r = requests.post(
|
|
f"{EVO_URL}/chat/findMessages/atlas",
|
|
headers={"apikey": EVO_KEY, "Content-Type": "application/json"},
|
|
json={"where": {"key": {"remoteJid": ENIS_JID, "fromMe": False}}, "limit": 20},
|
|
timeout=10
|
|
)
|
|
data = r.json()
|
|
records = data.get("messages", {}).get("records", []) if isinstance(data, dict) else data
|
|
new = [m for m in records if m.get("messageTimestamp", 0) > since_ts]
|
|
new.sort(key=lambda m: m.get("messageTimestamp", 0))
|
|
return new
|
|
except Exception as e:
|
|
log.error(f"fetch error: {e}")
|
|
return []
|
|
|
|
def extract_text(m):
|
|
msg = m.get("message", {})
|
|
if "conversation" in msg: return msg["conversation"]
|
|
if "extendedTextMessage" in msg: return msg["extendedTextMessage"].get("text", "")
|
|
if "imageMessage" in msg: return f"[image: {msg['imageMessage'].get('caption', '')}]"
|
|
if "audioMessage" in msg: return "[voice message]"
|
|
if "videoMessage" in msg: return "[video]"
|
|
if "documentMessage" in msg: return f"[file: {msg['documentMessage'].get('fileName', '')}]"
|
|
return ""
|
|
|
|
def _groq_key():
|
|
try:
|
|
txt = open('/opt/atlas/router-secrets.env').read()
|
|
m = re.search(r'GROQ_API_KEY=(.+)', txt)
|
|
return m.group(1).strip() if m else ''
|
|
except:
|
|
return ''
|
|
|
|
def generate_reply(user_text):
|
|
messages = [
|
|
{'role': 'system', 'content': SYSTEM_PROMPT},
|
|
{'role': 'user', 'content': user_text}
|
|
]
|
|
try:
|
|
r = requests.post(LLM_URL, json={
|
|
'model': 'atlas-free', 'messages': messages,
|
|
'max_tokens': 300, 'stream': False, 'temperature': 0.7
|
|
}, timeout=30)
|
|
result = r.json()['choices'][0]['message']['content'].strip()
|
|
if result:
|
|
log.info('replied via local LLM')
|
|
return result
|
|
except Exception as e:
|
|
log.warning(f'local LLM fail: {e}')
|
|
try:
|
|
key = _groq_key()
|
|
r = requests.post(
|
|
'https://api.groq.com/openai/v1/chat/completions',
|
|
json={'model': 'llama-3.3-70b-versatile', 'messages': messages, 'max_tokens': 300},
|
|
headers={'Authorization': f'Bearer {key}'}, timeout=30
|
|
)
|
|
result = r.json()['choices'][0]['message']['content'].strip()
|
|
log.info('replied via Groq')
|
|
return result
|
|
except Exception as e:
|
|
log.error(f'Groq fail: {e}')
|
|
return None
|
|
|
|
def send_reply(text):
|
|
try:
|
|
payload = {"number": ENIS_JID, "textMessage": {"text": text}}
|
|
r = requests.post(
|
|
f"{EVO_URL}/message/sendText/atlas",
|
|
headers={"apikey": EVO_KEY, "Content-Type": "application/json"},
|
|
json=payload, timeout=15
|
|
)
|
|
resp = r.json()
|
|
if resp.get("key"):
|
|
log.info(f"sent OK: id={resp['key'].get('id','?')}")
|
|
return True
|
|
log.warning(f"send failed: {resp}")
|
|
return False
|
|
except Exception as e:
|
|
log.error(f"send error: {e}")
|
|
return False
|
|
|
|
def main():
|
|
log.info("Enis monitor started")
|
|
state = load_state()
|
|
|
|
while True:
|
|
try:
|
|
msgs = get_new_enis_messages(state["last_ts"])
|
|
for m in msgs:
|
|
ts = m.get("messageTimestamp", 0)
|
|
mid = m.get("key", {}).get("id", "")
|
|
txt = extract_text(m)
|
|
if not txt or mid == state.get("last_id"):
|
|
state["last_ts"] = max(state["last_ts"], ts)
|
|
save_state(state)
|
|
continue
|
|
log.info(f"new msg ts={ts} id={mid}: {txt[:80]}")
|
|
reply = generate_reply(txt)
|
|
if reply:
|
|
time.sleep(2) # brief typing pause
|
|
ok = send_reply(reply)
|
|
log.info(f"reply={'sent' if ok else 'FAILED'}: {reply[:60]}")
|
|
state["last_ts"] = max(state["last_ts"], ts)
|
|
state["last_id"] = mid
|
|
save_state(state)
|
|
time.sleep(3) # gap between multi-message bursts
|
|
except Exception as e:
|
|
log.error(f"loop error: {e}")
|
|
time.sleep(POLL_SEC)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|