2025-12-15 16:11:47 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
connectd/api.py - REST API for stats and control
|
|
|
|
|
|
|
|
|
|
exposes daemon stats for home assistant integration.
|
|
|
|
|
runs on port 8099 by default.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import json
|
|
|
|
|
import threading
|
|
|
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
from db import Database
|
2025-12-15 17:04:55 +00:00
|
|
|
from db.users import get_priority_users, get_priority_user_matches, get_priority_user
|
2025-12-15 16:11:47 +00:00
|
|
|
|
|
|
|
|
API_PORT = int(os.environ.get('CONNECTD_API_PORT', 8099))
|
|
|
|
|
|
|
|
|
|
# shared state (updated by daemon)
|
|
|
|
|
_daemon_state = {
|
|
|
|
|
'running': False,
|
|
|
|
|
'dry_run': False,
|
|
|
|
|
'last_scout': None,
|
|
|
|
|
'last_match': None,
|
|
|
|
|
'last_intro': None,
|
|
|
|
|
'last_lost': None,
|
|
|
|
|
'intros_today': 0,
|
|
|
|
|
'lost_intros_today': 0,
|
|
|
|
|
'started_at': None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_daemon_state(state_dict):
|
|
|
|
|
"""update shared daemon state (called by daemon)"""
|
|
|
|
|
global _daemon_state
|
|
|
|
|
_daemon_state.update(state_dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_daemon_state():
|
|
|
|
|
"""get current daemon state"""
|
|
|
|
|
return _daemon_state.copy()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class APIHandler(BaseHTTPRequestHandler):
|
|
|
|
|
"""simple REST API handler"""
|
|
|
|
|
|
|
|
|
|
def log_message(self, format, *args):
|
|
|
|
|
"""suppress default logging"""
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _send_json(self, data, status=200):
|
|
|
|
|
"""send JSON response"""
|
|
|
|
|
self.send_response(status)
|
|
|
|
|
self.send_header('Content-Type', 'application/json')
|
|
|
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
|
|
|
self.end_headers()
|
|
|
|
|
self.wfile.write(json.dumps(data).encode())
|
|
|
|
|
|
|
|
|
|
def do_GET(self):
|
|
|
|
|
"""handle GET requests"""
|
|
|
|
|
if self.path == '/api/stats':
|
|
|
|
|
self._handle_stats()
|
|
|
|
|
elif self.path == '/api/health':
|
|
|
|
|
self._handle_health()
|
|
|
|
|
elif self.path == '/api/state':
|
|
|
|
|
self._handle_state()
|
2025-12-15 17:04:55 +00:00
|
|
|
elif self.path == '/api/priority_matches':
|
|
|
|
|
self._handle_priority_matches()
|
|
|
|
|
elif self.path == '/api/top_humans':
|
|
|
|
|
self._handle_top_humans()
|
|
|
|
|
elif self.path == '/api/user':
|
|
|
|
|
self._handle_user()
|
2025-12-15 16:11:47 +00:00
|
|
|
else:
|
|
|
|
|
self._send_json({'error': 'not found'}, 404)
|
|
|
|
|
|
|
|
|
|
def _handle_stats(self):
|
|
|
|
|
"""return database statistics"""
|
|
|
|
|
try:
|
|
|
|
|
db = Database()
|
|
|
|
|
stats = db.stats()
|
|
|
|
|
db.close()
|
|
|
|
|
self._send_json(stats)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self._send_json({'error': str(e)}, 500)
|
|
|
|
|
|
|
|
|
|
def _handle_health(self):
|
|
|
|
|
"""return daemon health status"""
|
|
|
|
|
state = get_daemon_state()
|
|
|
|
|
|
|
|
|
|
health = {
|
|
|
|
|
'status': 'running' if state['running'] else 'stopped',
|
|
|
|
|
'dry_run': state['dry_run'],
|
|
|
|
|
'uptime_seconds': None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if state['started_at']:
|
|
|
|
|
uptime = datetime.now() - datetime.fromisoformat(state['started_at'])
|
|
|
|
|
health['uptime_seconds'] = int(uptime.total_seconds())
|
|
|
|
|
|
|
|
|
|
self._send_json(health)
|
|
|
|
|
|
|
|
|
|
def _handle_state(self):
|
|
|
|
|
"""return full daemon state"""
|
|
|
|
|
state = get_daemon_state()
|
|
|
|
|
|
|
|
|
|
# convert datetimes to strings
|
|
|
|
|
for key in ['last_scout', 'last_match', 'last_intro', 'last_lost', 'started_at']:
|
|
|
|
|
if state[key] and isinstance(state[key], datetime):
|
|
|
|
|
state[key] = state[key].isoformat()
|
|
|
|
|
|
|
|
|
|
self._send_json(state)
|
|
|
|
|
|
2025-12-15 17:04:55 +00:00
|
|
|
def _handle_priority_matches(self):
|
|
|
|
|
"""return priority matches for HA sensor"""
|
|
|
|
|
try:
|
|
|
|
|
db = Database()
|
|
|
|
|
users = get_priority_users(db.conn)
|
|
|
|
|
|
|
|
|
|
if not users:
|
|
|
|
|
self._send_json({
|
|
|
|
|
'count': 0,
|
|
|
|
|
'new_count': 0,
|
|
|
|
|
'top_matches': [],
|
|
|
|
|
})
|
|
|
|
|
db.close()
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# get matches for first priority user (host)
|
|
|
|
|
user = users[0]
|
|
|
|
|
matches = get_priority_user_matches(db.conn, user['id'], limit=10)
|
|
|
|
|
|
|
|
|
|
new_count = sum(1 for m in matches if m.get('status') == 'new')
|
|
|
|
|
|
|
|
|
|
top_matches = []
|
|
|
|
|
for m in matches[:5]:
|
|
|
|
|
overlap_reasons = m.get('overlap_reasons', '[]')
|
|
|
|
|
if isinstance(overlap_reasons, str):
|
|
|
|
|
import json as json_mod
|
|
|
|
|
overlap_reasons = json_mod.loads(overlap_reasons) if overlap_reasons else []
|
|
|
|
|
|
|
|
|
|
top_matches.append({
|
|
|
|
|
'username': m.get('username'),
|
|
|
|
|
'platform': m.get('platform'),
|
|
|
|
|
'score': m.get('score', 0),
|
|
|
|
|
'overlap_score': m.get('overlap_score', 0),
|
|
|
|
|
'reasons': overlap_reasons[:3],
|
|
|
|
|
'url': m.get('url'),
|
|
|
|
|
'status': m.get('status', 'new'),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
db.close()
|
|
|
|
|
self._send_json({
|
|
|
|
|
'count': len(matches),
|
|
|
|
|
'new_count': new_count,
|
|
|
|
|
'top_matches': top_matches,
|
|
|
|
|
})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self._send_json({'error': str(e)}, 500)
|
|
|
|
|
|
|
|
|
|
def _handle_top_humans(self):
|
|
|
|
|
"""return top scoring humans for HA sensor"""
|
|
|
|
|
try:
|
|
|
|
|
db = Database()
|
|
|
|
|
humans = db.get_all_humans(min_score=50, limit=5)
|
|
|
|
|
|
|
|
|
|
top_humans = []
|
|
|
|
|
for h in humans:
|
|
|
|
|
contact = h.get('contact', '{}')
|
|
|
|
|
if isinstance(contact, str):
|
|
|
|
|
import json as json_mod
|
|
|
|
|
contact = json_mod.loads(contact) if contact else {}
|
|
|
|
|
|
|
|
|
|
signals = h.get('signals', '[]')
|
|
|
|
|
if isinstance(signals, str):
|
|
|
|
|
import json as json_mod
|
|
|
|
|
signals = json_mod.loads(signals) if signals else []
|
|
|
|
|
|
|
|
|
|
top_humans.append({
|
|
|
|
|
'username': h.get('username'),
|
|
|
|
|
'platform': h.get('platform'),
|
|
|
|
|
'score': h.get('score', 0),
|
|
|
|
|
'name': h.get('name'),
|
|
|
|
|
'signals': signals[:5],
|
|
|
|
|
'contact_method': 'email' if contact.get('email') else
|
|
|
|
|
'mastodon' if contact.get('mastodon') else
|
|
|
|
|
'matrix' if contact.get('matrix') else 'manual',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
db.close()
|
|
|
|
|
self._send_json({
|
|
|
|
|
'count': len(humans),
|
|
|
|
|
'top_humans': top_humans,
|
|
|
|
|
})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self._send_json({'error': str(e)}, 500)
|
|
|
|
|
|
|
|
|
|
def _handle_user(self):
|
|
|
|
|
"""return priority user info for HA sensor"""
|
|
|
|
|
try:
|
|
|
|
|
db = Database()
|
|
|
|
|
users = get_priority_users(db.conn)
|
|
|
|
|
|
|
|
|
|
if not users:
|
|
|
|
|
self._send_json({
|
|
|
|
|
'configured': False,
|
|
|
|
|
'score': 0,
|
|
|
|
|
'signals': [],
|
|
|
|
|
'match_count': 0,
|
|
|
|
|
})
|
|
|
|
|
db.close()
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
user = users[0]
|
|
|
|
|
signals = user.get('signals', '[]')
|
|
|
|
|
if isinstance(signals, str):
|
|
|
|
|
import json as json_mod
|
|
|
|
|
signals = json_mod.loads(signals) if signals else []
|
|
|
|
|
|
|
|
|
|
interests = user.get('interests', '[]')
|
|
|
|
|
if isinstance(interests, str):
|
|
|
|
|
import json as json_mod
|
|
|
|
|
interests = json_mod.loads(interests) if interests else []
|
|
|
|
|
|
|
|
|
|
matches = get_priority_user_matches(db.conn, user['id'], limit=100)
|
|
|
|
|
|
|
|
|
|
db.close()
|
|
|
|
|
self._send_json({
|
|
|
|
|
'configured': True,
|
|
|
|
|
'name': user.get('name'),
|
|
|
|
|
'github': user.get('github'),
|
|
|
|
|
'mastodon': user.get('mastodon'),
|
|
|
|
|
'reddit': user.get('reddit'),
|
|
|
|
|
'lobsters': user.get('lobsters'),
|
|
|
|
|
'matrix': user.get('matrix'),
|
|
|
|
|
'lemmy': user.get('lemmy'),
|
|
|
|
|
'discord': user.get('discord'),
|
|
|
|
|
'bluesky': user.get('bluesky'),
|
|
|
|
|
'score': user.get('score', 0),
|
|
|
|
|
'signals': signals[:10],
|
|
|
|
|
'interests': interests,
|
|
|
|
|
'location': user.get('location'),
|
|
|
|
|
'bio': user.get('bio'),
|
|
|
|
|
'match_count': len(matches),
|
|
|
|
|
'new_match_count': sum(1 for m in matches if m.get('status') == 'new'),
|
|
|
|
|
})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self._send_json({'error': str(e)}, 500)
|
|
|
|
|
|
2025-12-15 16:11:47 +00:00
|
|
|
|
|
|
|
|
def run_api_server():
|
|
|
|
|
"""run the API server in a thread"""
|
|
|
|
|
server = HTTPServer(('0.0.0.0', API_PORT), APIHandler)
|
|
|
|
|
print(f"connectd api running on port {API_PORT}")
|
|
|
|
|
server.serve_forever()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def start_api_thread():
|
|
|
|
|
"""start API server in background thread"""
|
|
|
|
|
thread = threading.Thread(target=run_api_server, daemon=True)
|
|
|
|
|
thread.start()
|
|
|
|
|
return thread
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
# standalone mode for testing
|
|
|
|
|
print(f"starting connectd api on port {API_PORT}...")
|
|
|
|
|
run_api_server()
|