mirror of
https://github.com/sudoxnym/connectd.git
synced 2026-04-14 11:37:42 +00:00
977 lines
42 KiB
Text
977 lines
42 KiB
Text
|
|
#!/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
|
||
|
|
from db.users import get_priority_users, get_priority_user_matches, get_priority_user
|
||
|
|
|
||
|
|
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()
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
DASHBOARD_HTML = """<!DOCTYPE html>
|
||
|
|
<html>
|
||
|
|
<head>
|
||
|
|
<title>connectd</title>
|
||
|
|
<meta charset=utf-8>
|
||
|
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||
|
|
<style>
|
||
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
||
|
|
body{font-family:monospace;background:#0a0a0f;color:#0f8;padding:20px}
|
||
|
|
h1{color:#c792ea;margin-bottom:15px}
|
||
|
|
h2{color:#82aaff;margin:15px 0 10px}
|
||
|
|
.stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:15px}
|
||
|
|
.stat{background:#1a1a2e;padding:10px 16px;border-radius:6px;border:1px solid #333;text-align:center}
|
||
|
|
.stat b{font-size:1.6em;color:#c792ea;display:block}
|
||
|
|
.stat small{color:#666;font-size:.75em}
|
||
|
|
.card{background:#1a1a2e;border:1px solid #333;border-radius:6px;padding:10px;margin-bottom:8px;cursor:pointer}
|
||
|
|
.card:hover{border-color:#0f8}
|
||
|
|
.card-hdr{display:flex;justify-content:space-between;color:#82aaff}
|
||
|
|
.score{background:#2a2a4e;padding:2px 8px;border-radius:4px;color:#c792ea}
|
||
|
|
.body{background:#0d0d15;padding:10px;border-radius:4px;white-space:pre-wrap;color:#ddd;margin-top:8px;font-size:.85em}
|
||
|
|
.meta{color:#666;font-size:.75em;margin-top:5px}
|
||
|
|
.m{display:inline-block;padding:1px 5px;border-radius:3px;font-size:.75em}
|
||
|
|
.m-email{background:#2d4a2d;color:#8f8}
|
||
|
|
.m-mastodon{background:#3d3a5c;color:#c792ea}
|
||
|
|
.m-new{background:#2d3a4a;color:#82aaff}
|
||
|
|
.tabs{margin-bottom:12px}
|
||
|
|
.tab{background:#1a1a2e;border:1px solid #333;color:#0f8;padding:6px 14px;cursor:pointer;font-family:monospace;font-size:.9em}
|
||
|
|
.tab.on{background:#2a2a4e;border-color:#0f8}
|
||
|
|
.pnl{display:none}
|
||
|
|
.pnl.on{display:block}
|
||
|
|
.btn{background:#0f8;color:#0a0a0f;border:none;padding:6px 14px;cursor:pointer;font-family:monospace;font-weight:bold;margin-left:10px;font-size:.9em}
|
||
|
|
.err{color:#f66}
|
||
|
|
a{color:#82aaff}
|
||
|
|
.status{font-size:.85em;color:#888;margin-bottom:10px}
|
||
|
|
.status b{color:#0f8}
|
||
|
|
.cached{color:#555;font-size:.7em}
|
||
|
|
.to{color:#f7c}
|
||
|
|
.about{color:#82aaff}
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<h1>connectd <a href="https://github.com/sudoxnym/connectd" style="font-size:.5em;color:#82aaff">repo</a> <a href="https://github.com/connectd-daemon" style="font-size:.5em;color:#f7c">org</a></h1>
|
||
|
|
<div class="status" id="status"></div>
|
||
|
|
<div class="stats" id="stats"></div>
|
||
|
|
<div class="tabs">
|
||
|
|
<button class="tab on" onclick="show('host')">you</button>
|
||
|
|
<button class="tab" onclick="show('queue')">queue</button>
|
||
|
|
<button class="tab" onclick="show('sent')">sent</button>
|
||
|
|
<button class="tab" onclick="show('failed')">failed</button>
|
||
|
|
<button class="btn" onclick="load()">refresh</button>
|
||
|
|
</div>
|
||
|
|
<div id="host" class="pnl on"></div>
|
||
|
|
<div id="queue" class="pnl"></div>
|
||
|
|
<div id="sent" class="pnl"></div>
|
||
|
|
<div id="failed" class="pnl"></div>
|
||
|
|
<script>
|
||
|
|
async function loadStats(){
|
||
|
|
var sr=await fetch('/api/stats'),hr=await fetch('/api/host');
|
||
|
|
var s=await sr.json(),h=await hr.json();
|
||
|
|
var up=h.uptime_seconds?Math.floor(h.uptime_seconds/3600)+'h '+Math.floor((h.uptime_seconds%3600)/60)+'m':'0m';
|
||
|
|
document.getElementById('status').innerHTML='daemon <b>'+(h.running?'ON':'OFF')+'</b> | '+up+' | '+h.intros_today+' today';
|
||
|
|
document.getElementById('stats').innerHTML='<div class="stat"><b>'+s.total_humans+'</b><small>humans</small></div><div class="stat"><b>'+s.total_matches+'</b><small>matches</small></div><div class="stat"><b>'+h.score_90_plus+'</b><small>90+</small></div><div class="stat"><b>'+h.score_80_89+'</b><small>80+</small></div><div class="stat"><b>'+h.matches_pending+'</b><small>queue</small></div><div class="stat"><b>'+s.sent_intros+'</b><small>sent</small></div>';
|
||
|
|
}
|
||
|
|
async function loadHost(){
|
||
|
|
var r=await fetch('/api/host_matches?limit=20'),d=await r.json();
|
||
|
|
var c='<h2>your matches ('+d.host+')</h2>';
|
||
|
|
c+='<p style="color:#666;font-size:.8em;margin-bottom:10px">each match = 2 intros (one to you, one to them)</p>';
|
||
|
|
if(!d.matches||!d.matches.length){c+='<div class="meta">no matches yet</div>';}
|
||
|
|
for(var i=0;i<(d.matches||[]).length;i++){
|
||
|
|
var m=d.matches[i];
|
||
|
|
c+='<div class="card" onclick="prevHost('+m.id+',1,this)"><div class="card-hdr"><span class="to">TO: you</span><span class="score">'+m.score+'</span></div><div class="meta"><span class="about">ABOUT: '+m.other_user+'</span> ('+m.other_platform+')</div><div class="meta">'+(m.reasons||[]).slice(0,2).join(', ')+'</div><div id="h'+m.id+'a" class="body" style="display:none"></div></div>';
|
||
|
|
c+='<div class="card" onclick="prevHost('+m.id+',2,this)"><div class="card-hdr"><span class="to">TO: '+m.other_user+'</span><span class="score">'+m.score+'</span></div><div class="meta"><span class="about">ABOUT: you</span></div><div class="meta">'+(m.contact||'no contact')+'</div><div id="h'+m.id+'b" class="body" style="display:none"></div></div>';
|
||
|
|
}
|
||
|
|
document.getElementById('host').innerHTML=c;
|
||
|
|
}
|
||
|
|
async function prevHost(id,dir,card){
|
||
|
|
var el=document.getElementById('h'+id+(dir==1?'a':'b'));
|
||
|
|
if(el.style.display!='none'){el.style.display='none';return;}
|
||
|
|
el.innerHTML='loading...';el.style.display='block';
|
||
|
|
var r=await fetch('/api/preview_host_draft?id='+id+'&dir='+(dir==1?'to_you':'to_them'));
|
||
|
|
var d=await r.json();
|
||
|
|
if(d.error){el.innerHTML='<span class="err">'+d.error+'</span>';}
|
||
|
|
else{el.innerHTML='<b>SUBJ:</b> '+d.subject+(d.cached?' <span class="cached">(cached)</span>':'')+'<br><br>'+d.draft;}
|
||
|
|
}
|
||
|
|
async function loadQueue(){
|
||
|
|
var r=await fetch('/api/pending_matches?limit=40'),d=await r.json();
|
||
|
|
var c='<h2>outreach queue</h2>';
|
||
|
|
if(!d.matches||!d.matches.length){c+='<div class="meta">empty</div>';}
|
||
|
|
for(var i=0;i<(d.matches||[]).length;i++){
|
||
|
|
var p=d.matches[i];
|
||
|
|
c+='<div class="card" onclick="prevQ('+p.id+',this)"><div class="card-hdr"><span class="to">TO: '+p.to_user+'</span><span class="score">'+p.score+'</span></div><div class="meta"><span class="about">ABOUT: '+p.about_user+'</span> | <span class="m m-'+(p.method||'new')+'">'+(p.method||'?')+'</span> '+(p.contact||'')+'</div><div id="q'+p.id+'_'+i+'" class="body" style="display:none"></div></div>';
|
||
|
|
}
|
||
|
|
document.getElementById('queue').innerHTML=c;
|
||
|
|
}
|
||
|
|
async function prevQ(id,card){
|
||
|
|
var el=card.querySelector('.body');
|
||
|
|
if(el.style.display!='none'){el.style.display='none';return;}
|
||
|
|
el.innerHTML='loading...';el.style.display='block';
|
||
|
|
var r=await fetch('/api/preview_draft?id='+id);
|
||
|
|
var d=await r.json();
|
||
|
|
if(d.error){el.innerHTML='<span class="err">'+d.error+'</span>';}
|
||
|
|
else{el.innerHTML='<b>TO:</b> '+d.to+'\n<b>ABOUT:</b> '+d.about+'\n<b>SUBJ:</b> '+d.subject+(d.cached?' <span class="cached">(cached)</span>':'')+'<br><br>'+d.draft;}
|
||
|
|
}
|
||
|
|
async function loadSent(){var r=await fetch('/api/sent_intros'),d=await r.json();var c='<h2>sent</h2>';for(var i=0;i<(d.sent||[]).length;i++){var s=d.sent[i];c+='<div class="card"><div class="card-hdr">TO: '+s.recipient_id+' <span class="m m-'+s.method+'">'+s.method+'</span></div><div class="body">'+(s.draft||'-')+'</div><div class="meta">'+s.timestamp+'</div></div>';}document.getElementById('sent').innerHTML=c;}
|
||
|
|
async function loadFailed(){var r=await fetch('/api/failed_intros'),d=await r.json();var c='<h2>failed</h2>';for(var i=0;i<(d.failed||[]).length;i++){var f=d.failed[i];c+='<div class="card"><div class="card-hdr">'+f.recipient_id+'</div><div class="meta err">'+f.error+'</div></div>';}document.getElementById('failed').innerHTML=c;}
|
||
|
|
function show(n){document.querySelectorAll('.pnl').forEach(function(e){e.classList.remove('on')});document.querySelectorAll('.tab').forEach(function(e){e.classList.remove('on')});document.getElementById(n).classList.add('on');event.target.classList.add('on');}
|
||
|
|
function load(){loadStats();loadHost();loadQueue();loadSent();loadFailed();}
|
||
|
|
load();setInterval(load,60000);
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>"""
|
||
|
|
|
||
|
|
|
||
|
|
# draft cache - stores generated drafts so they dont regenerate
|
||
|
|
_draft_cache = {}
|
||
|
|
|
||
|
|
def get_cached_draft(match_id, match_type='match'):
|
||
|
|
key = f"{match_type}:{match_id}"
|
||
|
|
return _draft_cache.get(key)
|
||
|
|
|
||
|
|
def cache_draft(match_id, draft_data, match_type='match'):
|
||
|
|
key = f"{match_type}:{match_id}"
|
||
|
|
_draft_cache[key] = draft_data
|
||
|
|
|
||
|
|
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"""
|
||
|
|
path = self.path.split('?')[0]
|
||
|
|
if path == '/favicon.png' or path == '/favicon.ico':
|
||
|
|
self._handle_favicon()
|
||
|
|
elif path == '/' or path == '/dashboard':
|
||
|
|
self._handle_dashboard()
|
||
|
|
elif path == '/api/stats':
|
||
|
|
self._handle_stats()
|
||
|
|
elif path == '/api/host':
|
||
|
|
self._handle_host()
|
||
|
|
elif path == '/api/host_matches':
|
||
|
|
self._handle_host_matches()
|
||
|
|
elif path == '/api/your_matches':
|
||
|
|
self._handle_your_matches()
|
||
|
|
elif path == '/api/preview_match_draft':
|
||
|
|
self._handle_preview_match_draft()
|
||
|
|
elif path == '/api/preview_host_draft':
|
||
|
|
self._handle_preview_host_draft()
|
||
|
|
elif path == '/api/preview_draft':
|
||
|
|
self._handle_preview_draft()
|
||
|
|
elif path == '/api/pending_about_you':
|
||
|
|
self._handle_pending_about_you()
|
||
|
|
elif path == '/api/pending_to_you':
|
||
|
|
self._handle_pending_to_you()
|
||
|
|
elif path == '/api/pending_matches':
|
||
|
|
self._handle_pending_matches()
|
||
|
|
elif path == '/api/sent_intros':
|
||
|
|
self._handle_sent_intros()
|
||
|
|
elif path == '/api/failed_intros':
|
||
|
|
self._handle_failed_intros()
|
||
|
|
elif path == '/api/health':
|
||
|
|
self._handle_health()
|
||
|
|
elif path == '/api/state':
|
||
|
|
self._handle_state()
|
||
|
|
elif path == '/api/priority_matches':
|
||
|
|
self._handle_priority_matches()
|
||
|
|
elif path == '/api/top_humans':
|
||
|
|
self._handle_top_humans()
|
||
|
|
elif path == '/api/user':
|
||
|
|
self._handle_user()
|
||
|
|
else:
|
||
|
|
self._send_json({'error': 'not found'}, 404)
|
||
|
|
def _handle_favicon(self):
|
||
|
|
from pathlib import Path
|
||
|
|
fav = Path('/app/data/favicon.png')
|
||
|
|
if fav.exists():
|
||
|
|
self.send_response(200)
|
||
|
|
self.send_header('Content-Type', 'image/png')
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(fav.read_bytes())
|
||
|
|
else:
|
||
|
|
self.send_response(404)
|
||
|
|
self.end_headers()
|
||
|
|
|
||
|
|
def _handle_dashboard(self):
|
||
|
|
self.send_response(200)
|
||
|
|
self.send_header("Content-Type", "text/html")
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(DASHBOARD_HTML.encode())
|
||
|
|
|
||
|
|
def _handle_sent_intros(self):
|
||
|
|
from pathlib import Path
|
||
|
|
log_path = Path("/app/data/delivery_log.json")
|
||
|
|
sent = []
|
||
|
|
if log_path.exists():
|
||
|
|
with open(log_path) as f:
|
||
|
|
log = json.load(f)
|
||
|
|
sent = log.get("sent", [])[-20:]
|
||
|
|
sent.reverse()
|
||
|
|
self._send_json({"sent": sent})
|
||
|
|
|
||
|
|
def _handle_failed_intros(self):
|
||
|
|
from pathlib import Path
|
||
|
|
log_path = Path("/app/data/delivery_log.json")
|
||
|
|
failed = []
|
||
|
|
if log_path.exists():
|
||
|
|
with open(log_path) as f:
|
||
|
|
log = json.load(f)
|
||
|
|
failed = log.get("failed", [])
|
||
|
|
self._send_json({"failed": failed})
|
||
|
|
|
||
|
|
def _handle_host(self):
|
||
|
|
"""daemon status and match stats"""
|
||
|
|
import sqlite3
|
||
|
|
state = get_daemon_state()
|
||
|
|
try:
|
||
|
|
conn = sqlite3.connect('/data/db/connectd.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute("SELECT COUNT(*) FROM matches WHERE status='pending' AND overlap_score >= 60")
|
||
|
|
pending = c.fetchone()[0]
|
||
|
|
c.execute("SELECT COUNT(*) FROM matches WHERE status='intro_sent'")
|
||
|
|
sent = c.fetchone()[0]
|
||
|
|
c.execute("SELECT COUNT(*) FROM matches WHERE status='rejected'")
|
||
|
|
rejected = c.fetchone()[0]
|
||
|
|
c.execute("SELECT COUNT(*) FROM matches")
|
||
|
|
total = c.fetchone()[0]
|
||
|
|
c.execute("SELECT COUNT(*) FROM matches WHERE overlap_score >= 90")
|
||
|
|
s90 = c.fetchone()[0]
|
||
|
|
c.execute("SELECT COUNT(*) FROM matches WHERE overlap_score >= 80 AND overlap_score < 90")
|
||
|
|
s80 = c.fetchone()[0]
|
||
|
|
c.execute("SELECT COUNT(*) FROM matches WHERE overlap_score >= 70 AND overlap_score < 80")
|
||
|
|
s70 = c.fetchone()[0]
|
||
|
|
c.execute("SELECT COUNT(*) FROM matches WHERE overlap_score >= 60 AND overlap_score < 70")
|
||
|
|
s60 = c.fetchone()[0]
|
||
|
|
conn.close()
|
||
|
|
except:
|
||
|
|
pending = sent = rejected = total = s90 = s80 = s70 = s60 = 0
|
||
|
|
uptime = None
|
||
|
|
if state.get('started_at'):
|
||
|
|
try:
|
||
|
|
start = datetime.fromisoformat(state['started_at']) if isinstance(state['started_at'], str) else state['started_at']
|
||
|
|
uptime = int((datetime.now() - start).total_seconds())
|
||
|
|
except: pass
|
||
|
|
self._send_json({
|
||
|
|
'running': state.get('running', False), 'dry_run': state.get('dry_run', False),
|
||
|
|
'uptime_seconds': uptime, 'intros_today': state.get('intros_today', 0),
|
||
|
|
'matches_pending': pending, 'matches_sent': sent, 'matches_rejected': rejected, 'matches_total': total,
|
||
|
|
'score_90_plus': s90, 'score_80_89': s80, 'score_70_79': s70, 'score_60_69': s60,
|
||
|
|
})
|
||
|
|
|
||
|
|
def _handle_your_matches(self):
|
||
|
|
"""matches involving the host - shows both directions"""
|
||
|
|
import sqlite3
|
||
|
|
import json as j
|
||
|
|
from db.users import get_priority_users
|
||
|
|
limit = 15
|
||
|
|
if '?' in self.path:
|
||
|
|
for p in self.path.split('?')[1].split('&'):
|
||
|
|
if p.startswith('limit='):
|
||
|
|
try: limit = int(p.split('=')[1])
|
||
|
|
except: pass
|
||
|
|
try:
|
||
|
|
db = Database()
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
if not users:
|
||
|
|
self._send_json({'matches': [], 'host': None})
|
||
|
|
db.close()
|
||
|
|
return
|
||
|
|
host = users[0]
|
||
|
|
host_name = host.get('github') or host.get('name')
|
||
|
|
conn = sqlite3.connect('/data/db/connectd.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute("""SELECT m.id, m.overlap_score, m.overlap_reasons, m.status,
|
||
|
|
h1.username, h1.platform, h1.contact,
|
||
|
|
h2.username, h2.platform, h2.contact
|
||
|
|
FROM matches m
|
||
|
|
JOIN humans h1 ON m.human_a_id = h1.id
|
||
|
|
JOIN humans h2 ON m.human_b_id = h2.id
|
||
|
|
WHERE (h1.username = ? OR h2.username = ?)
|
||
|
|
AND m.status = 'pending' AND m.overlap_score >= 60
|
||
|
|
ORDER BY m.overlap_score DESC LIMIT ?""", (host_name, host_name, limit))
|
||
|
|
matches = []
|
||
|
|
for row in c.fetchall():
|
||
|
|
if row[4] == host_name:
|
||
|
|
other_user, other_platform = row[7], row[8]
|
||
|
|
other_contact = j.loads(row[9]) if row[9] else {}
|
||
|
|
else:
|
||
|
|
other_user, other_platform = row[4], row[5]
|
||
|
|
other_contact = j.loads(row[6]) if row[6] else {}
|
||
|
|
reasons = j.loads(row[2]) if row[2] else []
|
||
|
|
matches.append({
|
||
|
|
'id': row[0], 'score': int(row[1]), 'reasons': reasons,
|
||
|
|
'status': row[3], 'other_user': other_user, 'other_platform': other_platform,
|
||
|
|
'contact': other_contact.get('email') or other_contact.get('mastodon') or ''
|
||
|
|
})
|
||
|
|
conn.close()
|
||
|
|
db.close()
|
||
|
|
self._send_json({'host': host_name, 'matches': matches})
|
||
|
|
except Exception as e:
|
||
|
|
self._send_json({'error': str(e)}, 500)
|
||
|
|
|
||
|
|
def _handle_preview_match_draft(self):
|
||
|
|
"""preview draft for a match - dir=to_you or to_them"""
|
||
|
|
import sqlite3
|
||
|
|
import json as j
|
||
|
|
from introd.groq_draft import draft_intro_with_llm
|
||
|
|
from db.users import get_priority_users
|
||
|
|
|
||
|
|
match_id = None
|
||
|
|
direction = 'to_you'
|
||
|
|
if '?' in self.path:
|
||
|
|
for p in self.path.split('?')[1].split('&'):
|
||
|
|
if p.startswith('id='):
|
||
|
|
try: match_id = int(p.split('=')[1])
|
||
|
|
except: pass
|
||
|
|
if p.startswith('dir='):
|
||
|
|
direction = p.split('=')[1]
|
||
|
|
|
||
|
|
if not match_id:
|
||
|
|
self._send_json({'error': 'need ?id=match_id'}, 400)
|
||
|
|
return
|
||
|
|
|
||
|
|
cache_key = f"{match_id}_{direction}"
|
||
|
|
cached = get_cached_draft(cache_key, 'match')
|
||
|
|
if cached:
|
||
|
|
cached['cached'] = True
|
||
|
|
self._send_json(cached)
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
db = Database()
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
if not users:
|
||
|
|
self._send_json({'error': 'no priority user'}, 404)
|
||
|
|
db.close()
|
||
|
|
return
|
||
|
|
host = users[0]
|
||
|
|
host_name = host.get('github') or host.get('name')
|
||
|
|
|
||
|
|
conn = sqlite3.connect('/data/db/connectd.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute("""SELECT h1.username, h1.platform, h1.contact, h1.extra,
|
||
|
|
h2.username, h2.platform, h2.contact, h2.extra,
|
||
|
|
m.overlap_score, m.overlap_reasons
|
||
|
|
FROM matches m
|
||
|
|
JOIN humans h1 ON m.human_a_id = h1.id
|
||
|
|
JOIN humans h2 ON m.human_b_id = h2.id
|
||
|
|
WHERE m.id = ?""", (match_id,))
|
||
|
|
row = c.fetchone()
|
||
|
|
conn.close()
|
||
|
|
db.close()
|
||
|
|
|
||
|
|
if not row:
|
||
|
|
self._send_json({'error': 'match not found'}, 404)
|
||
|
|
return
|
||
|
|
|
||
|
|
human_a = {'username': row[0], 'platform': row[1],
|
||
|
|
'contact': j.loads(row[2]) if row[2] else {},
|
||
|
|
'extra': j.loads(row[3]) if row[3] else {}}
|
||
|
|
human_b = {'username': row[4], 'platform': row[5],
|
||
|
|
'contact': j.loads(row[6]) if row[6] else {},
|
||
|
|
'extra': j.loads(row[7]) if row[7] else {}}
|
||
|
|
reasons = j.loads(row[9]) if row[9] else []
|
||
|
|
|
||
|
|
if human_a['username'] == host_name:
|
||
|
|
host_human, other_human = human_a, human_b
|
||
|
|
else:
|
||
|
|
host_human, other_human = human_b, human_a
|
||
|
|
|
||
|
|
if direction == 'to_you':
|
||
|
|
match_data = {'human_a': host_human, 'human_b': other_human,
|
||
|
|
'overlap_score': row[8], 'overlap_reasons': reasons}
|
||
|
|
recipient_name = host_name
|
||
|
|
about_name = other_human['username']
|
||
|
|
else:
|
||
|
|
match_data = {'human_a': other_human, 'human_b': host_human,
|
||
|
|
'overlap_score': row[8], 'overlap_reasons': reasons}
|
||
|
|
recipient_name = other_human['username']
|
||
|
|
about_name = host_name
|
||
|
|
|
||
|
|
result, error = draft_intro_with_llm(match_data, recipient='a', dry_run=True)
|
||
|
|
if error:
|
||
|
|
self._send_json({'error': error}, 500)
|
||
|
|
return
|
||
|
|
|
||
|
|
response = {
|
||
|
|
'match_id': match_id,
|
||
|
|
'direction': direction,
|
||
|
|
'to': recipient_name,
|
||
|
|
'about': about_name,
|
||
|
|
'subject': result.get('subject'),
|
||
|
|
'draft': result.get('draft'),
|
||
|
|
'score': row[8],
|
||
|
|
'cached': False,
|
||
|
|
}
|
||
|
|
cache_draft(cache_key, response, 'match')
|
||
|
|
self._send_json(response)
|
||
|
|
except Exception as e:
|
||
|
|
self._send_json({'error': str(e)}, 500)
|
||
|
|
|
||
|
|
def _handle_host_matches(self):
|
||
|
|
"""matches for priority user"""
|
||
|
|
import sqlite3
|
||
|
|
import json as j
|
||
|
|
from db.users import get_priority_users
|
||
|
|
limit = 20
|
||
|
|
if '?' in self.path:
|
||
|
|
for p in self.path.split('?')[1].split('&'):
|
||
|
|
if p.startswith('limit='):
|
||
|
|
try: limit = int(p.split('=')[1])
|
||
|
|
except: pass
|
||
|
|
try:
|
||
|
|
db = Database()
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
if not users:
|
||
|
|
self._send_json({'matches': [], 'host': None})
|
||
|
|
db.close()
|
||
|
|
return
|
||
|
|
host = users[0]
|
||
|
|
conn = sqlite3.connect('/data/db/connectd.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute("""SELECT pm.id, pm.overlap_score, pm.overlap_reasons, pm.status, h.username, h.platform, h.contact
|
||
|
|
FROM priority_matches pm JOIN humans h ON pm.matched_human_id = h.id
|
||
|
|
WHERE pm.priority_user_id = ? ORDER BY pm.overlap_score DESC LIMIT ?""", (host['id'], limit))
|
||
|
|
matches = []
|
||
|
|
for row in c.fetchall():
|
||
|
|
reasons = j.loads(row[2]) if row[2] else []
|
||
|
|
contact = j.loads(row[6]) if row[6] else {}
|
||
|
|
matches.append({'id': row[0], 'score': int(row[1]), 'reasons': reasons, 'status': row[3],
|
||
|
|
'other_user': row[4], 'other_platform': row[5],
|
||
|
|
'contact': contact.get('email') or contact.get('mastodon') or contact.get('github') or ''})
|
||
|
|
conn.close()
|
||
|
|
db.close()
|
||
|
|
self._send_json({'host': host.get('github') or host.get('name'), 'matches': matches})
|
||
|
|
except Exception as e:
|
||
|
|
self._send_json({'error': str(e)}, 500)
|
||
|
|
|
||
|
|
def _handle_preview_host_draft(self):
|
||
|
|
"""preview draft for a priority match - dir=to_you or to_them"""
|
||
|
|
import sqlite3
|
||
|
|
import json as j
|
||
|
|
from introd.groq_draft import draft_intro_with_llm
|
||
|
|
from db.users import get_priority_users
|
||
|
|
|
||
|
|
match_id = None
|
||
|
|
direction = 'to_you'
|
||
|
|
if '?' in self.path:
|
||
|
|
for p in self.path.split('?')[1].split('&'):
|
||
|
|
if p.startswith('id='):
|
||
|
|
try: match_id = int(p.split('=')[1])
|
||
|
|
except: pass
|
||
|
|
if p.startswith('dir='):
|
||
|
|
direction = p.split('=')[1]
|
||
|
|
|
||
|
|
if not match_id:
|
||
|
|
self._send_json({'error': 'need ?id=match_id'}, 400)
|
||
|
|
return
|
||
|
|
|
||
|
|
cache_key = f"host_{match_id}_{direction}"
|
||
|
|
cached = get_cached_draft(cache_key, 'host')
|
||
|
|
if cached:
|
||
|
|
cached['cached'] = True
|
||
|
|
self._send_json(cached)
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
db = Database()
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
if not users:
|
||
|
|
self._send_json({'error': 'no priority user'}, 404)
|
||
|
|
db.close()
|
||
|
|
return
|
||
|
|
host = users[0]
|
||
|
|
|
||
|
|
conn = sqlite3.connect('/data/db/connectd.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
# Get the matched human from priority_matches
|
||
|
|
c.execute("""SELECT h.username, h.platform, h.contact, h.extra, pm.overlap_score, pm.overlap_reasons
|
||
|
|
FROM priority_matches pm
|
||
|
|
JOIN humans h ON pm.matched_human_id = h.id
|
||
|
|
WHERE pm.id = ?""", (match_id,))
|
||
|
|
row = c.fetchone()
|
||
|
|
conn.close()
|
||
|
|
db.close()
|
||
|
|
|
||
|
|
if not row:
|
||
|
|
self._send_json({'error': 'match not found'}, 404)
|
||
|
|
return
|
||
|
|
|
||
|
|
# The matched person (who we found for the host)
|
||
|
|
other = {'username': row[0], 'platform': row[1],
|
||
|
|
'contact': j.loads(row[2]) if row[2] else {},
|
||
|
|
'extra': j.loads(row[3]) if row[3] else {}}
|
||
|
|
|
||
|
|
# Build host as human_a (recipient), other as human_b (subject)
|
||
|
|
host_human = {'username': host.get('github') or host.get('name'),
|
||
|
|
'platform': 'priority',
|
||
|
|
'contact': {'email': host.get('email'), 'mastodon': host.get('mastodon'), 'github': host.get('github')},
|
||
|
|
'extra': {'bio': host.get('bio'), 'interests': host.get('interests')}}
|
||
|
|
|
||
|
|
reasons = j.loads(row[5]) if row[5] else []
|
||
|
|
match_data = {'human_a': host_human, 'human_b': other,
|
||
|
|
'overlap_score': row[4], 'overlap_reasons': reasons}
|
||
|
|
|
||
|
|
# direction determines who gets the intro
|
||
|
|
if direction == 'to_you':
|
||
|
|
# intro TO host ABOUT other
|
||
|
|
match_data = {'human_a': host_human, 'human_b': other,
|
||
|
|
'overlap_score': row[4], 'overlap_reasons': reasons}
|
||
|
|
to_name = host.get('github') or host.get('name')
|
||
|
|
about_name = other['username']
|
||
|
|
else:
|
||
|
|
# intro TO other ABOUT host
|
||
|
|
match_data = {'human_a': other, 'human_b': host_human,
|
||
|
|
'overlap_score': row[4], 'overlap_reasons': reasons}
|
||
|
|
to_name = other['username']
|
||
|
|
about_name = host.get('github') or host.get('name')
|
||
|
|
|
||
|
|
result, error = draft_intro_with_llm(match_data, recipient='a', dry_run=True)
|
||
|
|
if error:
|
||
|
|
self._send_json({'error': error}, 500)
|
||
|
|
return
|
||
|
|
|
||
|
|
cache_key = f"host_{match_id}_{direction}"
|
||
|
|
response = {
|
||
|
|
'match_id': match_id,
|
||
|
|
'direction': direction,
|
||
|
|
'to': to_name,
|
||
|
|
'about': about_name,
|
||
|
|
'subject': result.get('subject'),
|
||
|
|
'draft': result.get('draft'),
|
||
|
|
'score': row[4],
|
||
|
|
'cached': False,
|
||
|
|
}
|
||
|
|
cache_draft(cache_key, response, 'host')
|
||
|
|
self._send_json(response)
|
||
|
|
except Exception as e:
|
||
|
|
self._send_json({'error': str(e)}, 500)
|
||
|
|
|
||
|
|
def _handle_preview_draft(self):
|
||
|
|
import sqlite3
|
||
|
|
import json as j
|
||
|
|
from introd.groq_draft import draft_intro_with_llm
|
||
|
|
|
||
|
|
match_id = None
|
||
|
|
if '?' in self.path:
|
||
|
|
for p in self.path.split('?')[1].split('&'):
|
||
|
|
if p.startswith('id='):
|
||
|
|
try: match_id = int(p.split('=')[1])
|
||
|
|
except: pass
|
||
|
|
|
||
|
|
if not match_id:
|
||
|
|
self._send_json({'error': 'need ?id=match_id'}, 400)
|
||
|
|
return
|
||
|
|
|
||
|
|
# check cache first
|
||
|
|
cached = get_cached_draft(match_id, 'queue')
|
||
|
|
if cached:
|
||
|
|
cached['cached'] = True
|
||
|
|
self._send_json(cached)
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
conn = sqlite3.connect('/data/db/connectd.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute("""SELECT h1.username, h1.platform, h1.contact, h1.extra,
|
||
|
|
h2.username, h2.platform, h2.contact, h2.extra,
|
||
|
|
m.overlap_score, m.overlap_reasons
|
||
|
|
FROM matches m
|
||
|
|
JOIN humans h1 ON m.human_a_id = h1.id
|
||
|
|
JOIN humans h2 ON m.human_b_id = h2.id
|
||
|
|
WHERE m.id = ?""", (match_id,))
|
||
|
|
row = c.fetchone()
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
if not row:
|
||
|
|
self._send_json({'error': 'match not found'}, 404)
|
||
|
|
return
|
||
|
|
|
||
|
|
human_a = {'username': row[0], 'platform': row[1],
|
||
|
|
'contact': j.loads(row[2]) if row[2] else {},
|
||
|
|
'extra': j.loads(row[3]) if row[3] else {}}
|
||
|
|
human_b = {'username': row[4], 'platform': row[5],
|
||
|
|
'contact': j.loads(row[6]) if row[6] else {},
|
||
|
|
'extra': j.loads(row[7]) if row[7] else {}}
|
||
|
|
reasons = j.loads(row[9]) if row[9] else []
|
||
|
|
|
||
|
|
match_data = {'human_a': human_a, 'human_b': human_b,
|
||
|
|
'overlap_score': row[8], 'overlap_reasons': reasons}
|
||
|
|
|
||
|
|
result, error = draft_intro_with_llm(match_data, recipient='a', dry_run=True)
|
||
|
|
if error:
|
||
|
|
self._send_json({'error': error}, 500)
|
||
|
|
return
|
||
|
|
|
||
|
|
response = {
|
||
|
|
'match_id': match_id,
|
||
|
|
'to': human_a['username'],
|
||
|
|
'about': human_b['username'],
|
||
|
|
'subject': result.get('subject'),
|
||
|
|
'draft': result.get('draft'),
|
||
|
|
'score': row[8],
|
||
|
|
'cached': False,
|
||
|
|
}
|
||
|
|
cache_draft(match_id, response, 'queue')
|
||
|
|
self._send_json(response)
|
||
|
|
except Exception as e:
|
||
|
|
self._send_json({'error': str(e)}, 500)
|
||
|
|
|
||
|
|
def _handle_pending_about_you(self):
|
||
|
|
"""pending intros where host is human_b (being introduced to others)"""
|
||
|
|
import sqlite3
|
||
|
|
import json as j
|
||
|
|
from db.users import get_priority_users
|
||
|
|
limit = 10
|
||
|
|
if '?' in self.path:
|
||
|
|
for p in self.path.split('?')[1].split('&'):
|
||
|
|
if p.startswith('limit='):
|
||
|
|
try: limit = int(p.split('=')[1])
|
||
|
|
except: pass
|
||
|
|
try:
|
||
|
|
db = Database()
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
if not users:
|
||
|
|
self._send_json({'matches': []})
|
||
|
|
db.close()
|
||
|
|
return
|
||
|
|
host = users[0]
|
||
|
|
host_name = host.get('github') or host.get('name')
|
||
|
|
conn = sqlite3.connect('/data/db/connectd.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute("""SELECT m.id, h1.username, h1.platform, h1.contact,
|
||
|
|
m.overlap_score, m.overlap_reasons
|
||
|
|
FROM matches m
|
||
|
|
JOIN humans h1 ON m.human_a_id = h1.id
|
||
|
|
JOIN humans h2 ON m.human_b_id = h2.id
|
||
|
|
WHERE h2.username = ? AND m.status = 'pending' AND m.overlap_score >= 60
|
||
|
|
ORDER BY m.overlap_score DESC LIMIT ?""", (host_name, limit))
|
||
|
|
matches = []
|
||
|
|
for row in c.fetchall():
|
||
|
|
contact = j.loads(row[3]) if row[3] else {}
|
||
|
|
reasons = j.loads(row[5]) if row[5] else []
|
||
|
|
method = 'email' if contact.get('email') else ('mastodon' if contact.get('mastodon') else None)
|
||
|
|
matches.append({'id': row[0], 'to_user': row[1], 'to_platform': row[2],
|
||
|
|
'score': int(row[4]), 'reasons': reasons[:3], 'method': method,
|
||
|
|
'contact': contact.get('email') or contact.get('mastodon') or ''})
|
||
|
|
conn.close()
|
||
|
|
db.close()
|
||
|
|
self._send_json({'matches': matches})
|
||
|
|
except Exception as e:
|
||
|
|
self._send_json({'error': str(e)}, 500)
|
||
|
|
|
||
|
|
def _handle_pending_to_you(self):
|
||
|
|
"""pending intros where host is human_a (receiving intro about others)"""
|
||
|
|
import sqlite3
|
||
|
|
import json as j
|
||
|
|
from db.users import get_priority_users
|
||
|
|
limit = 20
|
||
|
|
if '?' in self.path:
|
||
|
|
for p in self.path.split('?')[1].split('&'):
|
||
|
|
if p.startswith('limit='):
|
||
|
|
try: limit = int(p.split('=')[1])
|
||
|
|
except: pass
|
||
|
|
try:
|
||
|
|
db = Database()
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
if not users:
|
||
|
|
self._send_json({'matches': []})
|
||
|
|
db.close()
|
||
|
|
return
|
||
|
|
host = users[0]
|
||
|
|
conn = sqlite3.connect('/data/db/connectd.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute("""SELECT pm.id, h.username, h.platform, pm.overlap_score, pm.overlap_reasons
|
||
|
|
FROM priority_matches pm
|
||
|
|
JOIN humans h ON pm.matched_human_id = h.id
|
||
|
|
WHERE pm.priority_user_id = ? AND pm.status IN ('new', 'pending')
|
||
|
|
ORDER BY pm.overlap_score DESC LIMIT ?""", (host['id'], limit))
|
||
|
|
matches = []
|
||
|
|
for row in c.fetchall():
|
||
|
|
reasons = j.loads(row[4]) if row[4] else []
|
||
|
|
matches.append({'id': row[0], 'about_user': row[1], 'about_platform': row[2],
|
||
|
|
'score': int(row[3]), 'reasons': reasons[:3]})
|
||
|
|
conn.close()
|
||
|
|
db.close()
|
||
|
|
self._send_json({'matches': matches})
|
||
|
|
except Exception as e:
|
||
|
|
self._send_json({'error': str(e)}, 500)
|
||
|
|
|
||
|
|
def _handle_pending_matches(self):
|
||
|
|
"""pending matches - returns BOTH directions for each match"""
|
||
|
|
import sqlite3
|
||
|
|
import json as j
|
||
|
|
limit = 30
|
||
|
|
if '?' in self.path:
|
||
|
|
for p in self.path.split('?')[1].split('&'):
|
||
|
|
if p.startswith('limit='):
|
||
|
|
try: limit = int(p.split('=')[1])
|
||
|
|
except: pass
|
||
|
|
try:
|
||
|
|
conn = sqlite3.connect('/data/db/connectd.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute("""SELECT m.id, h1.username, h1.platform, h1.contact,
|
||
|
|
h2.username, h2.platform, h2.contact, m.overlap_score, m.overlap_reasons
|
||
|
|
FROM matches m
|
||
|
|
JOIN humans h1 ON m.human_a_id = h1.id
|
||
|
|
JOIN humans h2 ON m.human_b_id = h2.id
|
||
|
|
WHERE m.status = 'pending' AND m.overlap_score >= 60
|
||
|
|
ORDER BY m.overlap_score DESC LIMIT ?""", (limit // 2,))
|
||
|
|
matches = []
|
||
|
|
for row in c.fetchall():
|
||
|
|
contact_a = j.loads(row[3]) if row[3] else {}
|
||
|
|
contact_b = j.loads(row[6]) if row[6] else {}
|
||
|
|
reasons = j.loads(row[8]) if row[8] else []
|
||
|
|
# direction 1: TO human_a ABOUT human_b
|
||
|
|
method_a = 'email' if contact_a.get('email') else ('mastodon' if contact_a.get('mastodon') else None)
|
||
|
|
matches.append({'id': row[0], 'to_user': row[1], 'about_user': row[4],
|
||
|
|
'score': int(row[7]), 'reasons': reasons[:3], 'method': method_a,
|
||
|
|
'contact': contact_a.get('email') or contact_a.get('mastodon') or ''})
|
||
|
|
# direction 2: TO human_b ABOUT human_a
|
||
|
|
method_b = 'email' if contact_b.get('email') else ('mastodon' if contact_b.get('mastodon') else None)
|
||
|
|
matches.append({'id': row[0], 'to_user': row[4], 'about_user': row[1],
|
||
|
|
'score': int(row[7]), 'reasons': reasons[:3], 'method': method_b,
|
||
|
|
'contact': contact_b.get('email') or contact_b.get('mastodon') or ''})
|
||
|
|
conn.close()
|
||
|
|
self._send_json({'matches': matches})
|
||
|
|
except Exception as e:
|
||
|
|
self._send_json({'error': str(e)}, 500)
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
|
||
|
|
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()
|