add interest/warm intro system, profile pages

- add token-based identity system (no logins, link IS identity)
- add /interested/{token} page to see who wants to meet you
- add i d like to chat button on profile pages with ?t= param
- profile pages at connectd.sudoxreboot.com/{username}
- groq drafts now include profile link and interested inbox
- fix user_type inference from score (builders show as builder)
- pull github avatars automatically
- rename lost potential to isolation score, hide for active builders
- add central API endpoints for tokens and interest counts
- update intro templates with interest links
This commit is contained in:
root 2025-12-17 01:49:40 +00:00
parent 843d1b199d
commit 0a94f880b5
7 changed files with 1395 additions and 460 deletions

447
api.py
View file

@ -12,14 +12,22 @@ import threading
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime from datetime import datetime
from db import Database from central_client import CentralClient, get_client
from db.users import get_priority_users, get_priority_user_matches, get_priority_user from profile_page import render_profile
# central API config # central API config
import requests import requests
CENTRAL_API = os.environ.get('CONNECTD_CENTRAL_API', '') CENTRAL_API = os.environ.get('CONNECTD_CENTRAL_API', '')
CENTRAL_KEY = os.environ.get('CONNECTD_API_KEY', '') CENTRAL_KEY = os.environ.get('CONNECTD_API_KEY', '')
# global client instance
_central = None
def get_central():
global _central
if _central is None:
_central = get_client()
return _central
API_PORT = int(os.environ.get('CONNECTD_API_PORT', 8099)) API_PORT = int(os.environ.get('CONNECTD_API_PORT', 8099))
# shared state (updated by daemon) # shared state (updated by daemon)
@ -180,31 +188,18 @@ async function loadStats() {
uptime = hrs + 'h ' + mins + 'm'; uptime = hrs + 'h ' + mins + 'm';
} }
$('status').innerHTML = 'daemon <b>' + (h.running ? 'ON' : 'OFF') + '</b> | ' + uptime + ' | ' + h.intros_today + ' today'; $('status').innerHTML = 'daemon <b>' + (h.running ? 'ON' : 'OFF') + '</b> | ' + uptime + ' | ' + (h.intros_today || 0) + ' today | ' + (s.active_instances || 1) + ' instances';
var centralHtml = '';
if (s.central && !s.central.error) {
centralHtml = '<div style="margin-top:10px;padding-top:10px;border-top:1px solid #333">' +
'<div style="color:#82aaff;font-size:0.8em;margin-bottom:8px">// central api</div>' +
'<div class="stats">' +
'<div class="stat"><b>' + s.central.total_humans + '</b><small>humans</small></div>' +
'<div class="stat"><b>' + s.central.total_matches.toLocaleString() + '</b><small>matches</small></div>' +
'<div class="stat"><b>' + s.central.lost_builders + '</b><small>lost</small></div>' +
'<div class="stat"><b>' + s.central.intros_sent + '</b><small>sent</small></div>' +
'<div class="stat"><b>' + s.central.active_instances + '</b><small>instances</small></div>' +
'</div></div>';
}
$('stats').innerHTML = $('stats').innerHTML =
'<div style="color:#666;font-size:0.8em;margin-bottom:8px">// local</div>' +
'<div class="stats">' + '<div class="stats">' +
'<div class="stat"><b>' + s.total_humans + '</b><small>humans</small></div>' + '<div class="stat"><b>' + (s.total_humans || 0) + '</b><small>humans</small></div>' +
'<div class="stat"><b>' + s.total_matches + '</b><small>matches</small></div>' + '<div class="stat"><b>' + (s.total_matches || 0).toLocaleString() + '</b><small>matches</small></div>' +
'<div class="stat"><b>' + h.score_90_plus + '</b><small>90+</small></div>' + '<div class="stat"><b>' + (s.lost_builders || 0) + '</b><small>lost</small></div>' +
'<div class="stat"><b>' + h.score_80_89 + '</b><small>80+</small></div>' + '<div class="stat"><b>' + (s.builders || 0) + '</b><small>builders</small></div>' +
'<div class="stat"><b>' + h.matches_pending + '</b><small>queue</small></div>' + '<div class="stat"><b>' + (h.score_90_plus || 0) + '</b><small>90+</small></div>' +
'<div class="stat"><b>' + s.sent_intros + '</b><small>sent</small></div>' + '<div class="stat"><b>' + (h.score_80_89 || 0) + '</b><small>80+</small></div>' +
'</div>' + centralHtml; '<div class="stat"><b>' + (s.intros_sent || 0) + '</b><small>sent</small></div>' +
'</div>';
} }
async function loadHost() { async function loadHost() {
@ -343,28 +338,28 @@ async function loadFailed() {
$('failed').innerHTML = html; $('failed').innerHTML = html;
} }
async function loadLost() { async function loadLost() {
var res = await fetch("/api/lost_builders"); var res = await fetch('/api/lost_builders');
var data = await res.json(); var data = await res.json();
var html = "<h2>lost builders (" + (data.total || 0) + ")</h2>"; var html = '<h2>lost builders (' + (data.total || 0) + ')</h2>';
html += '<p style=\"color:#c792ea;font-size:0.8em;margin-bottom:10px\">people who need to see that someone like them made it</p>'; html += '<p style="color:#c792ea;font-size:0.8em;margin-bottom:10px">people who need to see that someone like them made it</p>';
if (!data.matches || data.matches.length === 0) { if (!data.matches || data.matches.length === 0) {
html += '<div class=\"meta\">no lost builders found</div>'; html += '<div class="meta">no lost builders found</div>';
} }
for (var i = 0; i < (data.matches || []).length; i++) { for (var i = 0; i < (data.matches || []).length; i++) {
var m = data.matches[i]; var m = data.matches[i];
html += '<div class=\"card\">"; html += '<div class="card">';
html += '<div class=\"card-hdr\"><span class=\"to\">LOST: " + m.lost_user + "</span><span class=\"score\">" + m.match_score + "</span></div>'; html += '<div class="card-hdr"><span class="to">LOST: ' + m.lost_user + '</span><span class="score">' + m.match_score + '</span></div>';
html += '<div class=\"meta\">lost: " + m.lost_score + " | values: " + m.values_score + "</div>'; html += '<div class="meta">lost: ' + m.lost_score + ' | values: ' + m.values_score + '</div>';
html += '<div class=\"meta\" style=\"color:#0f8\">BUILDER: " + m.builder + " (" + m.builder_platform + ")</div>'; html += '<div class="meta" style="color:#0f8">BUILDER: ' + m.builder + ' (' + m.builder_platform + ')</div>';
html += '<div class=\"meta\">score: " + m.builder_score + " | repos: " + m.builder_repos + " | stars: " + m.builder_stars + "</div>'; html += '<div class="meta">score: ' + m.builder_score + ' | repos: ' + m.builder_repos + ' | stars: ' + m.builder_stars + '</div>';
html += '<div class=\"meta\">shared: " + (m.shared || []).join(", ") + "</div>'; html += '<div class="meta">shared: ' + (m.shared || []).join(', ') + '</div>';
html += '</div>'; html += '</div>';
} }
$("lost").innerHTML = html; $('lost').innerHTML = html;
} }
@ -489,6 +484,13 @@ class APIHandler(BaseHTTPRequestHandler):
self._handle_user() self._handle_user()
elif path == '/api/lost_builders': elif path == '/api/lost_builders':
self._handle_lost_builders() self._handle_lost_builders()
elif path.startswith('/profile/'):
self._handle_profile_by_username()
elif path.startswith('/humans/') and not path.startswith('/api/'):
self._handle_profile_by_id()
elif path.startswith('/api/humans/') and path.endswith('/full'):
self._handle_human_full_json()
else: else:
self._send_json({'error': 'not found'}, 404) self._send_json({'error': 'not found'}, 404)
def _handle_favicon(self): def _handle_favicon(self):
@ -510,52 +512,44 @@ class APIHandler(BaseHTTPRequestHandler):
self.wfile.write(DASHBOARD_HTML.encode()) self.wfile.write(DASHBOARD_HTML.encode())
def _handle_sent_intros(self): def _handle_sent_intros(self):
from pathlib import Path try:
log_path = Path("/app/data/delivery_log.json") central = get_central()
sent = [] history = central.get_outreach_history(status='sent', limit=20)
if log_path.exists(): sent = [{'recipient_id': h.get('human_id'), 'method': h.get('sent_via', 'unknown'),
with open(log_path) as f: 'draft': h.get('draft', ''), 'timestamp': h.get('completed_at', '')}
log = json.load(f) for h in history]
sent = log.get("sent", [])[-20:] self._send_json({"sent": sent})
sent.reverse() except Exception as e:
self._send_json({"sent": sent}) self._send_json({"sent": [], "error": str(e)})
def _handle_failed_intros(self): def _handle_failed_intros(self):
from pathlib import Path try:
log_path = Path("/app/data/delivery_log.json") central = get_central()
failed = [] history = central.get_outreach_history(status='failed', limit=50)
if log_path.exists(): failed = [{'recipient_id': h.get('human_id'), 'error': h.get('error', 'unknown'),
with open(log_path) as f: 'timestamp': h.get('completed_at', '')}
log = json.load(f) for h in history]
failed = log.get("failed", []) self._send_json({"failed": failed})
self._send_json({"failed": failed}) except Exception as e:
self._send_json({"failed": [], "error": str(e)})
def _handle_host(self): def _handle_host(self):
"""daemon status and match stats""" """daemon status and match stats from central"""
import sqlite3
state = get_daemon_state() state = get_daemon_state()
try: try:
conn = sqlite3.connect('/data/db/connectd.db') central = get_central()
c = conn.cursor() stats = central.get_stats()
c.execute("SELECT COUNT(*) FROM matches WHERE status='pending' AND overlap_score >= 60") total = stats.get('total_matches', 0)
pending = c.fetchone()[0] sent = stats.get('intros_sent', 0)
c.execute("SELECT COUNT(*) FROM matches WHERE status='intro_sent'") pending = stats.get('pending_outreach', 0)
sent = c.fetchone()[0] # score distribution - sample high scores only
c.execute("SELECT COUNT(*) FROM matches WHERE status='rejected'") high_matches = central.get_matches(min_score=60, limit=5000)
rejected = c.fetchone()[0] s90 = sum(1 for m in high_matches if m.get('overlap_score', 0) >= 90)
c.execute("SELECT COUNT(*) FROM matches") s80 = sum(1 for m in high_matches if 80 <= m.get('overlap_score', 0) < 90)
total = c.fetchone()[0] s70 = sum(1 for m in high_matches if 70 <= m.get('overlap_score', 0) < 80)
c.execute("SELECT COUNT(*) FROM matches WHERE overlap_score >= 90") s60 = sum(1 for m in high_matches if 60 <= m.get('overlap_score', 0) < 70)
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: except:
pending = sent = rejected = total = s90 = s80 = s70 = s60 = 0 pending = sent = total = s90 = s80 = s70 = s60 = 0
uptime = None uptime = None
if state.get('started_at'): if state.get('started_at'):
try: try:
@ -565,7 +559,7 @@ class APIHandler(BaseHTTPRequestHandler):
self._send_json({ self._send_json({
'running': state.get('running', False), 'dry_run': state.get('dry_run', False), 'running': state.get('running', False), 'dry_run': state.get('dry_run', False),
'uptime_seconds': uptime, 'intros_today': state.get('intros_today', 0), 'uptime_seconds': uptime, 'intros_today': state.get('intros_today', 0),
'matches_pending': pending, 'matches_sent': sent, 'matches_rejected': rejected, 'matches_total': total, 'matches_pending': pending, 'matches_sent': sent, 'matches_rejected': 0, 'matches_total': total,
'score_90_plus': s90, 'score_80_89': s80, 'score_70_79': s70, 'score_60_69': s60, 'score_90_plus': s90, 'score_80_89': s80, 'score_70_79': s70, 'score_60_69': s60,
}) })
@ -720,10 +714,8 @@ class APIHandler(BaseHTTPRequestHandler):
self._send_json({'error': str(e)}, 500) self._send_json({'error': str(e)}, 500)
def _handle_host_matches(self): def _handle_host_matches(self):
"""matches for priority user""" """top matches from central"""
import sqlite3
import json as j import json as j
from db.users import get_priority_users
limit = 20 limit = 20
if '?' in self.path: if '?' in self.path:
for p in self.path.split('?')[1].split('&'): for p in self.path.split('?')[1].split('&'):
@ -731,28 +723,21 @@ class APIHandler(BaseHTTPRequestHandler):
try: limit = int(p.split('=')[1]) try: limit = int(p.split('=')[1])
except: pass except: pass
try: try:
db = Database() central = get_central()
users = get_priority_users(db.conn) raw_matches = central.get_matches(min_score=60, limit=limit)
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 = [] matches = []
for row in c.fetchall(): for m in raw_matches:
reasons = j.loads(row[2]) if row[2] else [] reasons = j.loads(m.get('overlap_reasons') or '[]') if isinstance(m.get('overlap_reasons'), str) else (m.get('overlap_reasons') or [])
contact = j.loads(row[6]) if row[6] else {} matches.append({
matches.append({'id': row[0], 'score': int(row[1]), 'reasons': reasons, 'status': row[3], 'id': m.get('id'),
'other_user': row[4], 'other_platform': row[5], 'score': int(m.get('overlap_score', 0)),
'contact': contact.get('email') or contact.get('mastodon') or contact.get('github') or ''}) 'reasons': reasons[:3] if isinstance(reasons, list) else [],
conn.close() 'status': 'pending',
db.close() 'other_user': m.get('human_b_username'),
self._send_json({'host': host.get('github') or host.get('name'), 'matches': matches}) 'other_platform': m.get('human_b_platform'),
'contact': ''
})
self._send_json({'host': 'central', 'matches': matches})
except Exception as e: except Exception as e:
self._send_json({'error': str(e)}, 500) self._send_json({'error': str(e)}, 500)
@ -1009,8 +994,7 @@ class APIHandler(BaseHTTPRequestHandler):
self._send_json({'error': str(e)}, 500) self._send_json({'error': str(e)}, 500)
def _handle_pending_matches(self): def _handle_pending_matches(self):
"""pending matches - returns BOTH directions for each match""" """pending matches from central - returns BOTH directions for each match"""
import sqlite3
import json as j import json as j
limit = 30 limit = 30
if '?' in self.path: if '?' in self.path:
@ -1019,60 +1003,40 @@ class APIHandler(BaseHTTPRequestHandler):
try: limit = int(p.split('=')[1]) try: limit = int(p.split('=')[1])
except: pass except: pass
try: try:
conn = sqlite3.connect('/data/db/connectd.db') central = get_central()
c = conn.cursor() raw_matches = central.get_matches(min_score=60, limit=limit // 2)
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 = [] matches = []
for row in c.fetchall(): for m in raw_matches:
contact_a = j.loads(row[3]) if row[3] else {} reasons = j.loads(m.get('overlap_reasons') or '[]') if isinstance(m.get('overlap_reasons'), str) else (m.get('overlap_reasons') or [])
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 # 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({
matches.append({'id': row[0], 'to_user': row[1], 'about_user': row[4], 'id': m.get('id'),
'score': int(row[7]), 'reasons': reasons[:3], 'method': method_a, 'to_user': m.get('human_a_username'),
'contact': contact_a.get('email') or contact_a.get('mastodon') or ''}) 'about_user': m.get('human_b_username'),
'score': int(m.get('overlap_score', 0)),
'reasons': reasons[:3] if isinstance(reasons, list) else [],
'method': 'pending',
'contact': ''
})
# direction 2: TO human_b ABOUT human_a # 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({
matches.append({'id': row[0], 'to_user': row[4], 'about_user': row[1], 'id': m.get('id'),
'score': int(row[7]), 'reasons': reasons[:3], 'method': method_b, 'to_user': m.get('human_b_username'),
'contact': contact_b.get('email') or contact_b.get('mastodon') or ''}) 'about_user': m.get('human_a_username'),
conn.close() 'score': int(m.get('overlap_score', 0)),
'reasons': reasons[:3] if isinstance(reasons, list) else [],
'method': 'pending',
'contact': ''
})
self._send_json({'matches': matches}) self._send_json({'matches': matches})
except Exception as e: except Exception as e:
self._send_json({'error': str(e)}, 500) self._send_json({'error': str(e)}, 500)
def _handle_stats(self): def _handle_stats(self):
"""return database statistics (local + central)""" """return database statistics from central"""
try: try:
db = Database() central = get_central()
stats = db.stats() stats = central.get_stats()
db.close()
# add central API stats if configured
if CENTRAL_API and CENTRAL_KEY:
try:
headers = {'X-API-Key': CENTRAL_KEY}
resp = requests.get(f'{CENTRAL_API}/stats', headers=headers, timeout=5)
if resp.status_code == 200:
central = resp.json()
stats['central'] = {
'total_humans': central.get('total_humans', 0),
'lost_builders': central.get('lost_builders', 0),
'builders': central.get('builders', 0),
'total_matches': central.get('total_matches', 0),
'intros_sent': central.get('intros_sent', 0),
'active_instances': central.get('active_instances', 0),
}
except Exception as ce:
stats['central'] = {'error': str(ce)}
self._send_json(stats) self._send_json(stats)
except Exception as e: except Exception as e:
self._send_json({'error': str(e)}, 500) self._send_json({'error': str(e)}, 500)
@ -1243,49 +1207,190 @@ class APIHandler(BaseHTTPRequestHandler):
def _handle_lost_builders(self): def _handle_lost_builders(self):
"""return lost builders with their inspiring matches""" """return lost builders from central"""
try: try:
from matchd.lost import find_matches_for_lost_builders central = get_central()
db = Database() lost = central.get_lost_builders(min_score=30, limit=50)
matches, error = find_matches_for_lost_builders(db, min_lost_score=30, min_values_score=15, limit=50) builders = central.get_builders(min_score=50, limit=20)
# simple matching: pair lost builders with builders who have similar signals
result = { result = {
'total': len(matches) if matches else 0, 'total': len(lost),
'error': error, 'error': None if builders else 'no active builders available',
'matches': [] 'matches': []
} }
if matches: if lost and builders:
for m in matches: for l in lost[:50]:
lost = m.get('lost_user', {}) # find best matching builder
builder = m.get('inspiring_builder', {}) lost_signals = set(json.loads(l.get('signals') or '[]'))
result['matches'].append({ best_builder = None
'lost_user': lost.get('username'), best_score = 0
'lost_platform': lost.get('platform'),
'lost_score': lost.get('lost_potential_score', 0), for b in builders:
'values_score': lost.get('score', 0), builder_signals = set(json.loads(b.get('signals') or '[]'))
'builder': builder.get('username'), shared = lost_signals & builder_signals
'builder_platform': builder.get('platform'), score = len(shared) * 10 + b.get('score', 0) * 0.1
'builder_score': builder.get('score', 0), if score > best_score:
'builder_repos': m.get('builder_repos', 0), best_score = score
'builder_stars': m.get('builder_stars', 0), best_builder = b
'match_score': m.get('match_score', 0), best_shared = list(shared)
'shared': m.get('shared_interests', [])[:5],
}) if best_builder:
extra = json.loads(best_builder.get('extra') or '{}')
result['matches'].append({
'lost_user': l.get('username'),
'lost_platform': l.get('platform'),
'lost_score': l.get('lost_potential_score', 0),
'values_score': l.get('score', 0),
'builder': best_builder.get('username'),
'builder_platform': best_builder.get('platform'),
'builder_score': best_builder.get('score', 0),
'builder_repos': extra.get('public_repos', 0),
'builder_stars': extra.get('stars', 0),
'match_score': best_score,
'shared': best_shared[:5],
})
db.close()
self._send_json(result) self._send_json(result)
except Exception as e: except Exception as e:
self._send_json({'error': str(e)}, 500) self._send_json({'error': str(e)}, 500)
def _handle_profile_by_username(self):
"""render profile page by username"""
path = self.path.split('?')[0]
parts = path.strip('/').split('/')
if len(parts) == 2:
username = parts[1]
platform = None
elif len(parts) == 3:
platform = parts[1]
username = parts[2]
else:
self._send_json({'error': 'invalid path'}, 400)
return
try:
central = get_central()
if platform:
humans = central.get_humans(platform=platform, limit=100)
human = next((h for h in humans if h.get('username', '').lower() == username.lower()), None)
else:
human = None
for plat in ['github', 'reddit', 'mastodon', 'lobsters', 'lemmy']:
humans = central.get_humans(platform=plat, limit=500)
human = next((h for h in humans if h.get('username', '').lower() == username.lower()), None)
if human:
break
if not human:
self.send_response(404)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(f'<html><body style="background:#0a0a0f;color:#f66;font-family:monospace;padding:40px;"><h1>not found</h1><p>no human found with username: {username}</p><a href="/" style="color:#82aaff">back</a></body></html>'.encode())
return
matches = central.get_matches(limit=10000)
match_count = sum(1 for m in matches if m.get('human_a_id') == human['id'] or m.get('human_b_id') == human['id'])
html = render_profile(human, match_count=match_count)
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(html.encode())
except Exception as e:
self._send_json({'error': str(e)}, 500)
def _handle_profile_by_id(self):
"""render profile page by human ID"""
path = self.path.split('?')[0]
parts = path.strip('/').split('/')
if len(parts) != 2:
self._send_json({'error': 'invalid path'}, 400)
return
try:
human_id = int(parts[1])
except ValueError:
self._send_json({'error': 'invalid id'}, 400)
return
try:
central = get_central()
human = central.get_human(human_id)
if not human:
self.send_response(404)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(f'<html><body style="background:#0a0a0f;color:#f66;font-family:monospace;padding:40px;"><h1>not found</h1><p>no human found with id: {human_id}</p><a href="/" style="color:#82aaff">back</a></body></html>'.encode())
return
matches = central.get_matches(limit=10000)
match_count = sum(1 for m in matches if m.get('human_a_id') == human['id'] or m.get('human_b_id') == human['id'])
html = render_profile(human, match_count=match_count)
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(html.encode())
except Exception as e:
self._send_json({'error': str(e)}, 500)
def _handle_human_full_json(self):
"""return full human data as JSON"""
path = self.path.split('?')[0]
parts = path.strip('/').split('/')
if len(parts) != 4 or parts[0] != 'api' or parts[1] != 'humans' or parts[3] != 'full':
self._send_json({'error': 'invalid path'}, 400)
return
try:
human_id = int(parts[2])
except ValueError:
self._send_json({'error': 'invalid id'}, 400)
return
try:
central = get_central()
human = central.get_human(human_id)
if not human:
self._send_json({'error': 'not found'}, 404)
return
for field in ['signals', 'negative_signals', 'reasons', 'contact', 'extra']:
if field in human and isinstance(human[field], str):
try:
human[field] = json.loads(human[field])
except:
pass
matches = central.get_matches(limit=10000)
human['match_count'] = sum(1 for m in matches if m.get('human_a_id') == human['id'] or m.get('human_b_id') == human['id'])
self._send_json(human)
except Exception as e:
self._send_json({'error': str(e)}, 500)
def run_api_server(): def run_api_server():
"""run the API server in a thread""" """run the API server in a thread"""
server = HTTPServer(('0.0.0.0', API_PORT), APIHandler) server = HTTPServer(('0.0.0.0', API_PORT), APIHandler)
print(f"connectd api running on port {API_PORT}") print(f"connectd api running on port {API_PORT}")
server.serve_forever() server.serve_forever()
def start_api_thread(): def start_api_thread():
"""start API server in background thread""" """start API server in background thread"""
thread = threading.Thread(target=run_api_server, daemon=True) thread = threading.Thread(target=run_api_server, daemon=True)

View file

@ -178,6 +178,27 @@ class CentralClient:
return False return False
# convenience function
# === TOKENS ===
def get_token(self, user_id: int, match_id: int = None) -> str:
"""get or create a token for a user"""
params = {}
if match_id:
params['match_id'] = match_id
result = self._get(f'/api/token/{user_id}', params)
return result.get('token')
def get_interested_count(self, user_id: int) -> int:
"""get count of people interested in this user"""
try:
result = self._get(f'/api/interested_count/{user_id}')
return result.get('count', 0)
except:
return 0
# convenience function # convenience function
def get_client() -> CentralClient: def get_client() -> CentralClient:
return CentralClient() return CentralClient()

565
daemon.py
View file

@ -1,12 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
connectd daemon - continuous discovery and matchmaking connectd daemon - continuous discovery and matchmaking
REWIRED TO USE CENTRAL DATABASE
two modes of operation:
1. priority matching: find matches FOR hosts who run connectd
2. altruistic matching: connect strangers to each other
runs continuously, respects rate limits, sends intros automatically
""" """
import time import time
@ -19,7 +14,7 @@ from pathlib import Path
from db import Database from db import Database
from db.users import (init_users_table, get_priority_users, save_priority_match, from db.users import (init_users_table, get_priority_users, save_priority_match,
get_priority_user_matches, discover_host_user) get_priority_user_matches, discover_host_user, mark_match_viewed)
from scoutd import scrape_github, scrape_reddit, scrape_mastodon, scrape_lobsters, scrape_lemmy, scrape_discord from scoutd import scrape_github, scrape_reddit, scrape_mastodon, scrape_lobsters, scrape_lemmy, scrape_discord
from scoutd.forges import scrape_all_forges from scoutd.forges import scrape_all_forges
from config import HOST_USER from config import HOST_USER
@ -34,32 +29,41 @@ from introd.send import send_email
from introd.deliver import deliver_intro, determine_best_contact from introd.deliver import deliver_intro, determine_best_contact
from config import get_lost_config from config import get_lost_config
from api import start_api_thread, update_daemon_state from api import start_api_thread, update_daemon_state
from central_client import CentralClient, get_client
class DummyDb:
"""dummy db that does nothing - scrapers save here but we push to central"""
def save_human(self, human): pass
def save_match(self, *args, **kwargs): pass
def get_human(self, *args, **kwargs): return None
def close(self): pass
# daemon config # daemon config
SCOUT_INTERVAL = 3600 * 4 # full scout every 4 hours SCOUT_INTERVAL = 3600 * 4 # full scout every 4 hours
MATCH_INTERVAL = 3600 # check matches every hour MATCH_INTERVAL = 3600 # check matches every hour
INTRO_INTERVAL = 3600 * 2 # send intros every 2 hours INTRO_INTERVAL = 3600 * 2 # send intros every 2 hours
LOST_INTERVAL = 3600 * 6 # lost builder outreach every 6 hours (lower volume) LOST_INTERVAL = 3600 * 6 # lost builder outreach every 6 hours
from config import MAX_INTROS_PER_DAY from config import MAX_INTROS_PER_DAY
# central coordination (optional - for distributed instances) MIN_OVERLAP_PRIORITY = 30
try: MIN_OVERLAP_STRANGERS = 50
from central_client import CentralClient
CENTRAL_ENABLED = bool(os.environ.get('CONNECTD_API_KEY'))
except ImportError:
CENTRAL_ENABLED = False
CentralClient = None # from config.py
MIN_OVERLAP_PRIORITY = 30 # min score for priority user matches
MIN_OVERLAP_STRANGERS = 50 # higher bar for stranger intros
class ConnectDaemon: class ConnectDaemon:
def __init__(self, dry_run=False): def __init__(self, dry_run=False):
self.db = Database() # local db only for priority_users (host-specific)
init_users_table(self.db.conn) self.local_db = Database()
purged = self.db.purge_disqualified() init_users_table(self.local_db.conn)
if any(purged.values()):
self.log(f"purged disqualified: {purged}") # CENTRAL for all humans/matches
self.central = get_client()
if not self.central:
raise RuntimeError("CENTRAL API REQUIRED - set CONNECTD_API_KEY and CONNECTD_CENTRAL_API")
self.log("connected to CENTRAL database")
self.running = True self.running = True
self.dry_run = dry_run self.dry_run = dry_run
self.started_at = datetime.now() self.started_at = datetime.now()
@ -69,30 +73,19 @@ class ConnectDaemon:
self.last_lost = None self.last_lost = None
self.intros_today = 0 self.intros_today = 0
self.lost_intros_today = 0 self.lost_intros_today = 0
# central coordination
self.central = None
if CENTRAL_ENABLED:
try:
self.central = CentralClient()
instance_id = os.environ.get('CONNECTD_INSTANCE_ID', 'unknown')
self.central.register_instance(instance_id, os.environ.get('CONNECTD_INSTANCE_IP', 'unknown'))
self.log(f"connected to central API as {instance_id}")
except Exception as e:
self.log(f"central API unavailable: {e}")
self.central = None
self.today = datetime.now().date() self.today = datetime.now().date()
# handle shutdown gracefully # register instance
instance_id = os.environ.get('CONNECTD_INSTANCE_ID', 'daemon')
self.central.register_instance(instance_id, os.environ.get('CONNECTD_INSTANCE_IP', 'unknown'))
signal.signal(signal.SIGINT, self._shutdown) signal.signal(signal.SIGINT, self._shutdown)
signal.signal(signal.SIGTERM, self._shutdown) signal.signal(signal.SIGTERM, self._shutdown)
# auto-discover host user from env
if HOST_USER: if HOST_USER:
self.log(f"HOST_USER set: {HOST_USER}") self.log(f"HOST_USER set: {HOST_USER}")
discover_host_user(self.db.conn, HOST_USER) discover_host_user(self.local_db.conn, HOST_USER)
# update API state
self._update_api_state() self._update_api_state()
def _shutdown(self, signum, frame): def _shutdown(self, signum, frame):
@ -101,10 +94,8 @@ class ConnectDaemon:
self._update_api_state() self._update_api_state()
def _update_api_state(self): def _update_api_state(self):
"""update API state for HA integration"""
now = datetime.now() now = datetime.now()
# calculate countdowns - if no cycle has run, use started_at
def secs_until(last, interval): def secs_until(last, interval):
base = last if last else self.started_at base = last if last else self.started_at
next_run = base + timedelta(seconds=interval) next_run = base + timedelta(seconds=interval)
@ -128,123 +119,116 @@ class ConnectDaemon:
}) })
def log(self, msg): def log(self, msg):
"""timestamped log"""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}") print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}")
def reset_daily_limits(self): def reset_daily_limits(self):
"""reset daily intro count"""
if datetime.now().date() != self.today: if datetime.now().date() != self.today:
self.today = datetime.now().date() self.today = datetime.now().date()
self.intros_today = 0 self.intros_today = 0
self.lost_intros_today = 0 self.lost_intros_today = 0
# central coordination
self.central = None
if CENTRAL_ENABLED:
try:
self.central = CentralClient()
instance_id = os.environ.get('CONNECTD_INSTANCE_ID', 'unknown')
self.central.register_instance(instance_id, os.environ.get('CONNECTD_INSTANCE_IP', 'unknown'))
self.log(f"connected to central API as {instance_id}")
except Exception as e:
self.log(f"central API unavailable: {e}")
self.central = None
self.log("reset daily intro limits") self.log("reset daily intro limits")
def scout_cycle(self): def scout_cycle(self):
"""run discovery on all platforms""" """run discovery - scrape to CENTRAL"""
self.log("starting scout cycle...") self.log("starting scout cycle (-> CENTRAL)...")
# dummy db - scrapers save here but we push to central
dummy_db = DummyDb()
scraped_humans = []
try: try:
scrape_github(self.db, limit_per_source=30) # github - returns list of humans
from scoutd.github import scrape_github
gh_humans = scrape_github(dummy_db, limit_per_source=30)
if gh_humans:
scraped_humans.extend(gh_humans)
self.log(f" github: {len(gh_humans) if gh_humans else 0} humans")
except Exception as e: except Exception as e:
self.log(f"github scout error: {e}") self.log(f"github scout error: {e}")
try: try:
scrape_reddit(self.db, limit_per_sub=30) from scoutd.reddit import scrape_reddit
reddit_humans = scrape_reddit(dummy_db, limit_per_sub=30)
if reddit_humans:
scraped_humans.extend(reddit_humans)
self.log(f" reddit: {len(reddit_humans) if reddit_humans else 0} humans")
except Exception as e: except Exception as e:
self.log(f"reddit scout error: {e}") self.log(f"reddit scout error: {e}")
try: try:
scrape_mastodon(self.db, limit_per_instance=30) from scoutd.mastodon import scrape_mastodon
masto_humans = scrape_mastodon(dummy_db, limit_per_instance=30)
# scrape self-hosted git forges (highest signal) if masto_humans:
self.log("scraping self-hosted git forges...") scraped_humans.extend(masto_humans)
try: self.log(f" mastodon: {len(masto_humans) if masto_humans else 0} humans")
forge_humans = scrape_all_forges(limit_per_instance=30)
for h in forge_humans:
self.db.upsert_human(h)
self.log(f" forges: {len(forge_humans)} humans")
except Exception as e:
self.log(f" forge scrape error: {e}")
except Exception as e: except Exception as e:
self.log(f"mastodon scout error: {e}") self.log(f"mastodon scout error: {e}")
try: try:
scrape_lobsters(self.db) forge_humans = scrape_all_forges(limit_per_instance=30)
if forge_humans:
scraped_humans.extend(forge_humans)
self.log(f" forges: {len(forge_humans) if forge_humans else 0} humans")
except Exception as e:
self.log(f"forge scout error: {e}")
try:
from scoutd.lobsters import scrape_lobsters
lob_humans = scrape_lobsters(dummy_db)
if lob_humans:
scraped_humans.extend(lob_humans)
self.log(f" lobsters: {len(lob_humans) if lob_humans else 0} humans")
except Exception as e: except Exception as e:
self.log(f"lobsters scout error: {e}") self.log(f"lobsters scout error: {e}")
try: # push all to central
scrape_lemmy(self.db, limit_per_community=30) if scraped_humans:
except Exception as e: self.log(f"pushing {len(scraped_humans)} humans to CENTRAL...")
self.log(f"lemmy scout error: {e}") try:
created, updated = self.central.upsert_humans_bulk(scraped_humans)
try: self.log(f" central: {created} created, {updated} updated")
scrape_discord(self.db, limit_per_channel=50) except Exception as e:
except Exception as e: self.log(f" central push error: {e}")
self.log(f"discord scout error: {e}")
self.last_scout = datetime.now() self.last_scout = datetime.now()
stats = self.db.stats() stats = self.central.get_stats()
self.log(f"scout complete: {stats['total_humans']} humans in db") self.log(f"scout complete: {stats.get('total_humans', 0)} humans in CENTRAL")
def match_priority_users(self): def match_priority_users(self):
"""find matches for priority users (hosts)""" """find matches for priority users (hosts) using CENTRAL data"""
priority_users = get_priority_users(self.db.conn) priority_users = get_priority_users(self.local_db.conn)
if not priority_users: if not priority_users:
return return
self.log(f"matching for {len(priority_users)} priority users...") self.log(f"matching for {len(priority_users)} priority users (from CENTRAL)...")
humans = self.db.get_all_humans(min_score=20) # get humans from CENTRAL
humans = self.central.get_all_humans(min_score=20)
for puser in priority_users: for puser in priority_users:
# build priority user's fingerprint from their linked profiles # use stored signals first (from discovery/scoring)
puser_signals = [] puser_signals = []
puser_text = [] if puser.get('signals'):
stored = puser['signals']
if isinstance(stored, str):
try:
stored = json.loads(stored)
except:
stored = []
puser_signals.extend(stored)
if puser.get('bio'): # supplement with interests if no signals stored
puser_text.append(puser['bio']) if not puser_signals and puser.get('interests'):
if puser.get('interests'):
interests = json.loads(puser['interests']) if isinstance(puser['interests'], str) else puser['interests'] interests = json.loads(puser['interests']) if isinstance(puser['interests'], str) else puser['interests']
puser_signals.extend(interests) puser_signals.extend(interests)
if puser.get('looking_for'):
puser_text.append(puser['looking_for'])
# analyze their linked github if available if not puser_signals:
if puser.get('github'): self.log(f" skipping {puser.get('name')} - no signals")
gh_user = analyze_github_user(puser['github']) continue
if gh_user:
puser_signals.extend(gh_user.get('signals', []))
puser_fingerprint = {
'values_vector': {},
'skills': {},
'interests': list(set(puser_signals)),
'location_pref': 'pnw' if puser.get('location') and 'seattle' in puser['location'].lower() else None,
}
# score text
if puser_text:
_, text_signals, _ = analyze_text(' '.join(puser_text))
puser_signals.extend(text_signals)
# find matches
matches_found = 0 matches_found = 0
for human in humans: for human in humans:
# skip if it's their own profile on another platform
human_user = human.get('username', '').lower() human_user = human.get('username', '').lower()
if puser.get('github') and human_user == puser['github'].lower(): if puser.get('github') and human_user == puser['github'].lower():
continue continue
@ -253,17 +237,18 @@ class ConnectDaemon:
if puser.get('mastodon') and human_user == puser['mastodon'].lower().split('@')[0]: if puser.get('mastodon') and human_user == puser['mastodon'].lower().split('@')[0]:
continue continue
# calculate overlap
human_signals = human.get('signals', []) human_signals = human.get('signals', [])
if isinstance(human_signals, str): if isinstance(human_signals, str):
human_signals = json.loads(human_signals) try:
human_signals = json.loads(human_signals)
except:
human_signals = []
shared = set(puser_signals) & set(human_signals) shared = set(puser_signals) & set(human_signals)
overlap_score = len(shared) * 10 overlap_score = len(shared) * 10
# location bonus
if puser.get('location') and human.get('location'): if puser.get('location') and human.get('location'):
if 'seattle' in human['location'].lower() or 'pnw' in human['location'].lower(): if 'seattle' in str(human.get('location', '')).lower() or 'pnw' in str(human.get('location', '')).lower():
overlap_score += 20 overlap_score += 20
if overlap_score >= MIN_OVERLAP_PRIORITY: if overlap_score >= MIN_OVERLAP_PRIORITY:
@ -271,33 +256,31 @@ class ConnectDaemon:
'overlap_score': overlap_score, 'overlap_score': overlap_score,
'overlap_reasons': [f"shared: {', '.join(list(shared)[:5])}"] if shared else [], 'overlap_reasons': [f"shared: {', '.join(list(shared)[:5])}"] if shared else [],
} }
save_priority_match(self.db.conn, puser['id'], human['id'], overlap_data) save_priority_match(self.local_db.conn, puser['id'], human['id'], overlap_data)
matches_found += 1 matches_found += 1
if matches_found: if matches_found:
self.log(f" found {matches_found} matches for {puser['name'] or puser['email']}") self.log(f" found {matches_found} matches for {puser['name'] or puser['email']}")
def match_strangers(self): def match_strangers(self):
"""find matches between discovered humans (altruistic)""" """find matches between discovered humans - save to CENTRAL"""
self.log("matching strangers...") self.log("matching strangers (-> CENTRAL)...")
humans = self.db.get_all_humans(min_score=40) humans = self.central.get_all_humans(min_score=40)
if len(humans) < 2: if len(humans) < 2:
return return
# generate fingerprints
fingerprints = {} fingerprints = {}
for human in humans: for human in humans:
fp = generate_fingerprint(human) fp = generate_fingerprint(human)
fingerprints[human['id']] = fp fingerprints[human['id']] = fp
# find pairs
matches_found = 0 matches_found = 0
new_matches = []
from itertools import combinations from itertools import combinations
for human_a, human_b in combinations(humans, 2): for human_a, human_b in combinations(humans, 2):
# skip same platform same user
if human_a['platform'] == human_b['platform']: if human_a['platform'] == human_b['platform']:
if human_a['username'] == human_b['username']: if human_a['username'] == human_b['username']:
continue continue
@ -308,71 +291,35 @@ class ConnectDaemon:
overlap = find_overlap(human_a, human_b, fp_a, fp_b) overlap = find_overlap(human_a, human_b, fp_a, fp_b)
if overlap and overlap["overlap_score"] >= MIN_OVERLAP_STRANGERS: if overlap and overlap["overlap_score"] >= MIN_OVERLAP_STRANGERS:
# save match new_matches.append({
self.db.save_match(human_a['id'], human_b['id'], overlap) 'human_a_id': human_a['id'],
'human_b_id': human_b['id'],
'overlap_score': overlap['overlap_score'],
'overlap_reasons': json.dumps(overlap.get('overlap_reasons', []))
})
matches_found += 1 matches_found += 1
if matches_found: # bulk push to central
self.log(f"found {matches_found} stranger matches") if new_matches:
self.log(f"pushing {len(new_matches)} matches to CENTRAL...")
try:
created = self.central.create_matches_bulk(new_matches)
self.log(f" central: {created} matches created")
except Exception as e:
self.log(f" central push error: {e}")
self.last_match = datetime.now() self.last_match = datetime.now()
def claim_from_central(self, human_id, match_id=None, outreach_type='intro'):
"""claim outreach from central - returns outreach_id or None if already claimed"""
if not self.central:
return -1 # local mode, always allow
try:
return self.central.claim_outreach(human_id, match_id, outreach_type)
except Exception as e:
self.log(f"central claim error: {e}")
return -1 # allow local if central fails
def complete_on_central(self, outreach_id, status, sent_via=None, draft=None, error=None):
"""mark outreach complete on central"""
if not self.central or outreach_id == -1:
return
try:
self.central.complete_outreach(outreach_id, status, sent_via, draft, error)
except Exception as e:
self.log(f"central complete error: {e}")
def sync_to_central(self, humans=None, matches=None):
"""sync local data to central"""
if not self.central:
return
try:
if humans:
self.central.upsert_humans_bulk(humans)
if matches:
self.central.create_matches_bulk(matches)
except Exception as e:
self.log(f"central sync error: {e}")
def send_stranger_intros(self): def send_stranger_intros(self):
"""send intros to connect strangers (or preview in dry-run mode)""" """send intros using CENTRAL data"""
self.reset_daily_limits() self.reset_daily_limits()
if not self.dry_run and self.intros_today >= MAX_INTROS_PER_DAY: if not self.dry_run and self.intros_today >= MAX_INTROS_PER_DAY:
self.log("daily intro limit reached") self.log("daily intro limit reached")
return return
# get unsent matches # get pending matches from CENTRAL
c = self.db.conn.cursor() matches = self.central.get_matches(min_score=MIN_OVERLAP_STRANGERS, limit=20)
c.execute('''SELECT m.*,
ha.id as a_id, ha.username as a_user, ha.platform as a_platform,
ha.name as a_name, ha.url as a_url, ha.contact as a_contact,
ha.signals as a_signals, ha.extra as a_extra,
hb.id as b_id, hb.username as b_user, hb.platform as b_platform,
hb.name as b_name, hb.url as b_url, hb.contact as b_contact,
hb.signals as b_signals, hb.extra as b_extra
FROM matches m
JOIN humans ha ON m.human_a_id = ha.id
JOIN humans hb ON m.human_b_id = hb.id
WHERE m.status = 'pending'
ORDER BY m.overlap_score DESC
LIMIT 10''')
matches = c.fetchall()
if self.dry_run: if self.dry_run:
self.log(f"DRY RUN: previewing {len(matches)} potential intros") self.log(f"DRY RUN: previewing {len(matches)} potential intros")
@ -381,59 +328,60 @@ class ConnectDaemon:
if not self.dry_run and self.intros_today >= MAX_INTROS_PER_DAY: if not self.dry_run and self.intros_today >= MAX_INTROS_PER_DAY:
break break
match = dict(match) # get full human data
human_a = self.central.get_human(match['human_a_id'])
human_b = self.central.get_human(match['human_b_id'])
# build human dicts if not human_a or not human_b:
human_a = { continue
'id': match['a_id'],
'username': match['a_user'],
'platform': match['a_platform'],
'name': match['a_name'],
'url': match['a_url'],
'contact': match['a_contact'],
'signals': match['a_signals'],
'extra': match['a_extra'],
}
human_b = {
'id': match['b_id'],
'username': match['b_user'],
'platform': match['b_platform'],
'name': match['b_name'],
'url': match['b_url'],
'contact': match['b_contact'],
'signals': match['b_signals'],
'extra': match['b_extra'],
}
match_data = { match_data = {
'id': match['id'], 'id': match['id'],
'human_a': human_a, 'human_a': human_a,
'human_b': human_b, 'human_b': human_b,
'overlap_score': match['overlap_score'], 'overlap_score': match['overlap_score'],
'overlap_reasons': match['overlap_reasons'], 'overlap_reasons': match.get('overlap_reasons', ''),
} }
# try to send intro to person with email
for recipient, other in [(human_a, human_b), (human_b, human_a)]: for recipient, other in [(human_a, human_b), (human_b, human_a)]:
contact = recipient.get('contact', {}) contact = recipient.get('contact', {})
if isinstance(contact, str): if isinstance(contact, str):
contact = json.loads(contact) try:
contact = json.loads(contact)
except:
contact = {}
email = contact.get('email') email = contact.get('email')
if not email: if not email:
continue continue
# draft intro # check if already contacted
intro = draft_intro(match_data, recipient='a' if recipient == human_a else 'b') if self.central.already_contacted(recipient['id']):
continue
# parse overlap reasons for display # get token and interest count for recipient
reasons = match['overlap_reasons'] try:
recipient_token = self.central.get_token(recipient['id'], match.get('id'))
interested_count = self.central.get_interested_count(recipient['id'])
except Exception as e:
print(f"[intro] failed to get token/count: {e}")
recipient_token = None
interested_count = 0
intro = draft_intro(match_data,
recipient='a' if recipient == human_a else 'b',
recipient_token=recipient_token,
interested_count=interested_count)
reasons = match.get('overlap_reasons', '')
if isinstance(reasons, str): if isinstance(reasons, str):
reasons = json.loads(reasons) try:
reasons = json.loads(reasons)
except:
reasons = []
reason_summary = ', '.join(reasons[:3]) if reasons else 'aligned values' reason_summary = ', '.join(reasons[:3]) if reasons else 'aligned values'
if self.dry_run: if self.dry_run:
# print preview
print("\n" + "=" * 60) print("\n" + "=" * 60)
print(f"TO: {recipient['username']} ({recipient['platform']})") print(f"TO: {recipient['username']} ({recipient['platform']})")
print(f"EMAIL: {email}") print(f"EMAIL: {email}")
@ -447,13 +395,11 @@ class ConnectDaemon:
print("=" * 60) print("=" * 60)
break break
else: else:
# claim from central first outreach_id = self.central.claim_outreach(recipient['id'], match['id'], 'intro')
outreach_id = self.claim_from_central(recipient['id'], match['id'], 'intro')
if outreach_id is None: if outreach_id is None:
self.log(f"skipping {recipient['username']} - already claimed by another instance") self.log(f"skipping {recipient['username']} - already claimed")
continue continue
# actually send
success, error = send_email( success, error = send_email(
email, email,
f"connectd: you might want to meet {other['username']}", f"connectd: you might want to meet {other['username']}",
@ -463,24 +409,124 @@ class ConnectDaemon:
if success: if success:
self.log(f"sent intro to {recipient['username']} ({email})") self.log(f"sent intro to {recipient['username']} ({email})")
self.intros_today += 1 self.intros_today += 1
self.complete_on_central(outreach_id, 'sent', 'email', intro['draft']) self.central.complete_outreach(outreach_id, 'sent', 'email', intro['draft'])
# mark match as intro_sent
c.execute('UPDATE matches SET status = "intro_sent" WHERE id = ?',
(match['id'],))
self.db.conn.commit()
break break
else: else:
self.log(f"failed to send to {email}: {error}") self.log(f"failed to send to {email}: {error}")
self.complete_on_central(outreach_id, 'failed', error=error) self.central.complete_outreach(outreach_id, 'failed', error=error)
self.last_intro = datetime.now() self.last_intro = datetime.now()
def send_priority_user_intros(self):
"""send intros TO priority users (hosts) about their matches"""
self.reset_daily_limits()
priority_users = get_priority_users(self.local_db.conn)
if not priority_users:
return
self.log(f"checking intros for {len(priority_users)} priority users...")
for puser in priority_users:
if not self.dry_run and self.intros_today >= MAX_INTROS_PER_DAY:
break
# get email
email = puser.get('email')
if not email:
continue
# get their matches from local priority_matches table
matches = get_priority_user_matches(self.local_db.conn, puser['id'], status='new', limit=5)
if not matches:
continue
for match in matches:
if not self.dry_run and self.intros_today >= MAX_INTROS_PER_DAY:
break
# get the matched human from CENTRAL (matched_human_id is central id)
human_id = match.get('matched_human_id')
if not human_id:
continue
human = self.central.get_human(human_id)
if not human:
continue
# build match data for drafting
overlap_reasons = match.get('overlap_reasons', '[]')
if isinstance(overlap_reasons, str):
try:
overlap_reasons = json.loads(overlap_reasons)
except:
overlap_reasons = []
puser_name = puser.get('name') or puser.get('email', '').split('@')[0]
human_name = human.get('name') or human.get('username')
# draft intro TO priority user ABOUT the matched human
match_data = {
'id': match.get('id'),
'human_a': {
'username': puser_name,
'platform': 'host',
'name': puser_name,
'bio': puser.get('bio', ''),
'signals': puser.get('signals', []),
},
'human_b': human,
'overlap_score': match.get('overlap_score', 0),
'overlap_reasons': overlap_reasons,
}
# try to get token for priority user (they might have a central ID)
recipient_token = None
interested_count = 0
if puser.get('central_id'):
try:
recipient_token = self.central.get_token(puser['central_id'], match.get('id'))
interested_count = self.central.get_interested_count(puser['central_id'])
except:
pass
intro = draft_intro(match_data, recipient='a',
recipient_token=recipient_token,
interested_count=interested_count)
reason_summary = ', '.join(overlap_reasons[:3]) if overlap_reasons else 'aligned values'
if self.dry_run:
print("\n" + "=" * 60)
print("PRIORITY USER INTRO")
print("=" * 60)
print(f"TO: {puser_name} ({email})")
print(f"ABOUT: {human_name} ({human.get('platform')})")
print(f"SCORE: {match.get('overlap_score', 0):.0f} ({reason_summary})")
print("-" * 60)
print("MESSAGE:")
print(intro['draft'])
print("-" * 60)
print("[DRY RUN - NOT SENT]")
print("=" * 60)
else:
success, error = send_email(
email,
f"connectd: you might want to meet {human_name}",
intro['draft']
)
if success:
self.log(f"sent priority intro to {puser_name} about {human_name}")
self.intros_today += 1
# mark match as notified
mark_match_viewed(self.local_db.conn, match['id'])
else:
self.log(f"failed to send priority intro to {email}: {error}")
def send_lost_builder_intros(self): def send_lost_builder_intros(self):
""" """reach out to lost builders using CENTRAL data"""
reach out to lost builders - different tone, lower volume.
these people need encouragement, not networking.
"""
self.reset_daily_limits() self.reset_daily_limits()
lost_config = get_lost_config() lost_config = get_lost_config()
@ -493,43 +539,60 @@ class ConnectDaemon:
self.log("daily lost builder intro limit reached") self.log("daily lost builder intro limit reached")
return return
# find lost builders with matching active builders # get lost builders from CENTRAL
matches, error = find_matches_for_lost_builders( lost_builders = self.central.get_lost_builders(
self.db, min_score=lost_config.get('min_lost_score', 40),
min_lost_score=lost_config.get('min_lost_score', 40),
min_values_score=lost_config.get('min_values_score', 20),
limit=max_per_day - self.lost_intros_today limit=max_per_day - self.lost_intros_today
) )
if error: # get active builders from CENTRAL
self.log(f"lost builder matching error: {error}") builders = self.central.get_builders(min_score=50, limit=100)
return
if not matches: if not lost_builders or not builders:
self.log("no lost builders ready for outreach") self.log("no lost builders or builders available")
return return
if self.dry_run: if self.dry_run:
self.log(f"DRY RUN: previewing {len(matches)} lost builder intros") self.log(f"DRY RUN: previewing {len(lost_builders)} lost builder intros")
for match in matches: for lost in lost_builders:
if not self.dry_run and self.lost_intros_today >= max_per_day: if not self.dry_run and self.lost_intros_today >= max_per_day:
break break
lost = match['lost_user'] # find matching builder
builder = match['inspiring_builder'] best_builder = None
best_score = 0
for builder in builders:
lost_signals = lost.get('signals', [])
builder_signals = builder.get('signals', [])
if isinstance(lost_signals, str):
try:
lost_signals = json.loads(lost_signals)
except:
lost_signals = []
if isinstance(builder_signals, str):
try:
builder_signals = json.loads(builder_signals)
except:
builder_signals = []
shared = set(lost_signals) & set(builder_signals)
if len(shared) > best_score:
best_score = len(shared)
best_builder = builder
if not best_builder:
continue
lost_name = lost.get('name') or lost.get('username') lost_name = lost.get('name') or lost.get('username')
builder_name = builder.get('name') or builder.get('username') builder_name = best_builder.get('name') or best_builder.get('username')
# draft intro draft, draft_error = draft_lost_intro(lost, best_builder, lost_config)
draft, draft_error = draft_lost_intro(lost, builder, lost_config)
if draft_error: if draft_error:
self.log(f"error drafting lost intro for {lost_name}: {draft_error}") self.log(f"error drafting lost intro for {lost_name}: {draft_error}")
continue continue
# determine best contact method (activity-based)
method, contact_info = determine_best_contact(lost) method, contact_info = determine_best_contact(lost)
if self.dry_run: if self.dry_run:
@ -539,9 +602,7 @@ class ConnectDaemon:
print(f"TO: {lost_name} ({lost.get('platform')})") print(f"TO: {lost_name} ({lost.get('platform')})")
print(f"DELIVERY: {method}{contact_info}") print(f"DELIVERY: {method}{contact_info}")
print(f"LOST SCORE: {lost.get('lost_potential_score', 0)}") print(f"LOST SCORE: {lost.get('lost_potential_score', 0)}")
print(f"VALUES SCORE: {lost.get('score', 0)}")
print(f"INSPIRING BUILDER: {builder_name}") print(f"INSPIRING BUILDER: {builder_name}")
print(f"SHARED INTERESTS: {', '.join(match.get('shared_interests', []))}")
print("-" * 60) print("-" * 60)
print("MESSAGE:") print("MESSAGE:")
print(draft) print(draft)
@ -549,12 +610,11 @@ class ConnectDaemon:
print("[DRY RUN - NOT SENT]") print("[DRY RUN - NOT SENT]")
print("=" * 60) print("=" * 60)
else: else:
# build match data for unified delivery
match_data = { match_data = {
'human_a': builder, # inspiring builder 'human_a': best_builder,
'human_b': lost, # lost builder (recipient) 'human_b': lost,
'overlap_score': match.get('match_score', 0), 'overlap_score': best_score * 10,
'overlap_reasons': match.get('shared_interests', []), 'overlap_reasons': [],
} }
success, error, delivery_method = deliver_intro(match_data, draft) success, error, delivery_method = deliver_intro(match_data, draft)
@ -562,7 +622,6 @@ class ConnectDaemon:
if success: if success:
self.log(f"sent lost builder intro to {lost_name} via {delivery_method}") self.log(f"sent lost builder intro to {lost_name} via {delivery_method}")
self.lost_intros_today += 1 self.lost_intros_today += 1
self.db.mark_lost_outreach(lost['id'])
else: else:
self.log(f"failed to reach {lost_name} via {delivery_method}: {error}") self.log(f"failed to reach {lost_name} via {delivery_method}: {error}")
@ -571,9 +630,8 @@ class ConnectDaemon:
def run(self): def run(self):
"""main daemon loop""" """main daemon loop"""
self.log("connectd daemon starting...") self.log("connectd daemon starting (CENTRAL MODE)...")
# start API server
start_api_thread() start_api_thread()
self.log("api server started on port 8099") self.log("api server started on port 8099")
@ -592,36 +650,31 @@ class ConnectDaemon:
while self.running: while self.running:
now = datetime.now() now = datetime.now()
# scout cycle
if not self.last_scout or (now - self.last_scout).seconds >= SCOUT_INTERVAL: if not self.last_scout or (now - self.last_scout).seconds >= SCOUT_INTERVAL:
self.scout_cycle() self.scout_cycle()
self._update_api_state() self._update_api_state()
# match cycle
if not self.last_match or (now - self.last_match).seconds >= MATCH_INTERVAL: if not self.last_match or (now - self.last_match).seconds >= MATCH_INTERVAL:
self.match_priority_users() self.match_priority_users()
self.match_strangers() self.match_strangers()
self._update_api_state() self._update_api_state()
# intro cycle
if not self.last_intro or (now - self.last_intro).seconds >= INTRO_INTERVAL: if not self.last_intro or (now - self.last_intro).seconds >= INTRO_INTERVAL:
self.send_stranger_intros() self.send_stranger_intros()
self.send_priority_user_intros()
self._update_api_state() self._update_api_state()
# lost builder cycle
if not self.last_lost or (now - self.last_lost).seconds >= LOST_INTERVAL: if not self.last_lost or (now - self.last_lost).seconds >= LOST_INTERVAL:
self.send_lost_builder_intros() self.send_lost_builder_intros()
self._update_api_state() self._update_api_state()
# sleep between checks
time.sleep(60) time.sleep(60)
self.log("connectd daemon stopped") self.log("connectd daemon stopped")
self.db.close() self.local_db.close()
def run_daemon(dry_run=False): def run_daemon(dry_run=False):
"""entry point"""
daemon = ConnectDaemon(dry_run=dry_run) daemon = ConnectDaemon(dry_run=dry_run)
daemon.run() daemon.run()

View file

@ -139,20 +139,18 @@ def save_priority_match(conn, priority_user_id, human_id, overlap_data):
def get_priority_user_matches(conn, priority_user_id, status=None, limit=50): def get_priority_user_matches(conn, priority_user_id, status=None, limit=50):
"""get matches for a priority user""" """get matches for a priority user (humans fetched from CENTRAL separately)"""
c = conn.cursor() c = conn.cursor()
if status: if status:
c.execute('''SELECT pm.*, h.* FROM priority_matches pm c.execute('''SELECT * FROM priority_matches
JOIN humans h ON pm.matched_human_id = h.id WHERE priority_user_id = ? AND status = ?
WHERE pm.priority_user_id = ? AND pm.status = ? ORDER BY overlap_score DESC
ORDER BY pm.overlap_score DESC
LIMIT ?''', (priority_user_id, status, limit)) LIMIT ?''', (priority_user_id, status, limit))
else: else:
c.execute('''SELECT pm.*, h.* FROM priority_matches pm c.execute('''SELECT * FROM priority_matches
JOIN humans h ON pm.matched_human_id = h.id WHERE priority_user_id = ?
WHERE pm.priority_user_id = ? ORDER BY overlap_score DESC
ORDER BY pm.overlap_score DESC
LIMIT ?''', (priority_user_id, limit)) LIMIT ?''', (priority_user_id, limit))
return [dict(row) for row in c.fetchall()] return [dict(row) for row in c.fetchall()]

View file

@ -1,10 +1,14 @@
""" """
introd/draft.py - AI writes intro messages referencing both parties' work introd/draft.py - AI writes intro messages referencing both parties' work
now with interest system links
""" """
import json import json
# intro template - transparent about being AI, neutral third party # base URL for connectd profiles
CONNECTD_URL = "https://connectd.sudoxreboot.com"
# intro template - now with interest links
INTRO_TEMPLATE = """hi {recipient_name}, INTRO_TEMPLATE = """hi {recipient_name},
i'm an AI that connects isolated builders working on similar things. i'm an AI that connects isolated builders working on similar things.
@ -17,7 +21,8 @@ overlap: {overlap_summary}
thought you might benefit from knowing each other. thought you might benefit from knowing each other.
their work: {other_url} their profile: {profile_url}
{interested_line}
no pitch. just connection. ignore if not useful. no pitch. just connection. ignore if not useful.
@ -32,7 +37,7 @@ you: {recipient_summary}
overlap: {overlap_summary} overlap: {overlap_summary}
their work: {other_url} their profile: {profile_url}
no pitch, just connection. no pitch, just connection.
""" """
@ -51,12 +56,18 @@ def summarize_human(human_data):
# signals/interests # signals/interests
signals = human_data.get('signals', []) signals = human_data.get('signals', [])
if isinstance(signals, str): if isinstance(signals, str):
signals = json.loads(signals) try:
signals = json.loads(signals)
except:
signals = []
# extra data # extra data
extra = human_data.get('extra', {}) extra = human_data.get('extra', {})
if isinstance(extra, str): if isinstance(extra, str):
extra = json.loads(extra) try:
extra = json.loads(extra)
except:
extra = {}
# build summary based on available data # build summary based on available data
topics = extra.get('topics', []) topics = extra.get('topics', [])
@ -103,7 +114,10 @@ def summarize_overlap(overlap_data):
"""generate overlap summary""" """generate overlap summary"""
reasons = overlap_data.get('overlap_reasons', []) reasons = overlap_data.get('overlap_reasons', [])
if isinstance(reasons, str): if isinstance(reasons, str):
reasons = json.loads(reasons) try:
reasons = json.loads(reasons)
except:
reasons = []
if reasons: if reasons:
return ' | '.join(reasons[:3]) return ' | '.join(reasons[:3])
@ -116,12 +130,14 @@ def summarize_overlap(overlap_data):
return "aligned values and interests" return "aligned values and interests"
def draft_intro(match_data, recipient='a'): def draft_intro(match_data, recipient='a', recipient_token=None, interested_count=0):
""" """
draft an intro message for a match draft an intro message for a match
match_data: dict with human_a, human_b, overlap info match_data: dict with human_a, human_b, overlap info
recipient: 'a' or 'b' - who receives this intro recipient: 'a' or 'b' - who receives this intro
recipient_token: token for the recipient (to track who clicked)
interested_count: how many people are already interested in the recipient
returns: dict with draft text, channel, metadata returns: dict with draft text, channel, metadata
""" """
@ -135,19 +151,37 @@ def draft_intro(match_data, recipient='a'):
# get names # get names
recipient_name = recipient_human.get('name') or recipient_human.get('username', 'friend') recipient_name = recipient_human.get('name') or recipient_human.get('username', 'friend')
other_name = other_human.get('name') or other_human.get('username', 'someone') other_name = other_human.get('name') or other_human.get('username', 'someone')
other_username = other_human.get('username', '')
# generate summaries # generate summaries
recipient_summary = summarize_human(recipient_human) recipient_summary = summarize_human(recipient_human)
other_summary = summarize_human(other_human) other_summary = summarize_human(other_human)
overlap_summary = summarize_overlap(match_data) overlap_summary = summarize_overlap(match_data)
# other's url # build profile URL with token if available
other_url = other_human.get('url', '') if other_username:
profile_url = f"{CONNECTD_URL}/{other_username}"
if recipient_token:
profile_url += f"?t={recipient_token}"
else:
profile_url = other_human.get('url', '')
# interested line - tells them about their inbox
interested_line = ''
if recipient_token:
interested_url = f"{CONNECTD_URL}/interested/{recipient_token}"
if interested_count > 0:
interested_line = f"\n{interested_count} people already want to meet you: {interested_url}"
else:
interested_line = f"\nbe the first to connect: {interested_url}"
# determine best channel # determine best channel
contact = recipient_human.get('contact', {}) contact = recipient_human.get('contact', {})
if isinstance(contact, str): if isinstance(contact, str):
contact = json.loads(contact) try:
contact = json.loads(contact)
except:
contact = {}
channel = None channel = None
channel_address = None channel_address = None
@ -156,15 +190,12 @@ def draft_intro(match_data, recipient='a'):
if contact.get('email'): if contact.get('email'):
channel = 'email' channel = 'email'
channel_address = contact['email'] channel_address = contact['email']
# github issue/discussion
elif recipient_human.get('platform') == 'github': elif recipient_human.get('platform') == 'github':
channel = 'github' channel = 'github'
channel_address = recipient_human.get('url') channel_address = recipient_human.get('url')
# mastodon DM
elif recipient_human.get('platform') == 'mastodon': elif recipient_human.get('platform') == 'mastodon':
channel = 'mastodon' channel = 'mastodon'
channel_address = recipient_human.get('username') channel_address = recipient_human.get('username')
# reddit message
elif recipient_human.get('platform') == 'reddit': elif recipient_human.get('platform') == 'reddit':
channel = 'reddit' channel = 'reddit'
channel_address = recipient_human.get('username') channel_address = recipient_human.get('username')
@ -180,12 +211,13 @@ def draft_intro(match_data, recipient='a'):
# render draft # render draft
draft = template.format( draft = template.format(
recipient_name=recipient_name.split()[0] if recipient_name else 'friend', # first name only recipient_name=recipient_name.split()[0] if recipient_name else 'friend',
recipient_summary=recipient_summary, recipient_summary=recipient_summary,
other_name=other_name.split()[0] if other_name else 'someone', other_name=other_name.split()[0] if other_name else 'someone',
other_summary=other_summary, other_summary=other_summary,
overlap_summary=overlap_summary, overlap_summary=overlap_summary,
other_url=other_url, profile_url=profile_url,
interested_line=interested_line,
) )
return { return {
@ -196,15 +228,16 @@ def draft_intro(match_data, recipient='a'):
'draft': draft, 'draft': draft,
'overlap_score': match_data.get('overlap_score', 0), 'overlap_score': match_data.get('overlap_score', 0),
'match_id': match_data.get('id'), 'match_id': match_data.get('id'),
'recipient_token': recipient_token,
} }
def draft_intros_for_match(match_data): def draft_intros_for_match(match_data, token_a=None, token_b=None, interested_a=0, interested_b=0):
""" """
draft intros for both parties in a match draft intros for both parties in a match
returns list of two intro dicts returns list of two intro dicts
""" """
intro_a = draft_intro(match_data, recipient='a') intro_a = draft_intro(match_data, recipient='a', recipient_token=token_a, interested_count=interested_a)
intro_b = draft_intro(match_data, recipient='b') intro_b = draft_intro(match_data, recipient='b', recipient_token=token_b, interested_count=interested_b)
return [intro_a, intro_b] return [intro_a, intro_b]

View file

@ -71,7 +71,7 @@ email: connectd@sudoxreboot.com
""" """
def draft_intro_with_llm(match_data: dict, recipient: str = 'a', dry_run: bool = True): def draft_intro_with_llm(match_data: dict, recipient: str = 'a', dry_run: bool = True, recipient_token: str = None, interested_count: int = 0):
""" """
draft an intro message using groq llm. draft an intro message using groq llm.
@ -219,9 +219,45 @@ return ONLY the subject line."""
subject = subject_response.choices[0].message.content.strip().strip('"').strip("'") subject = subject_response.choices[0].message.content.strip().strip('"').strip("'")
# add profile link and interest section
profile_url = f"https://connectd.sudoxreboot.com/{about_name}"
if recipient_token:
profile_url += f"?t={recipient_token}"
profile_section_html = f"""
<div style="margin-top: 20px; padding: 16px; background: #2d1f3d; border: 1px solid #8b5cf6; border-radius: 8px;">
<div style="color: #c792ea; font-size: 14px; margin-bottom: 8px;">here's the profile we built for {about_name}:</div>
<a href="{profile_url}" style="color: #82aaff; font-size: 16px;">{profile_url}</a>
</div>
"""
profile_section_plain = f"""
---
here's the profile we built for {about_name}:
{profile_url}
"""
# add interested section if recipient has people wanting to chat
interest_section_html = ""
interest_section_plain = ""
if recipient_token and interested_count > 0:
interest_url = f"https://connectd.sudoxreboot.com/interested/{recipient_token}"
people_word = "person wants" if interested_count == 1 else "people want"
interest_section_html = f"""
<div style="margin-top: 12px; padding: 16px; background: #1f2d3d; border: 1px solid #0f8; border-radius: 8px;">
<div style="color: #0f8; font-size: 14px;">{interested_count} {people_word} to chat with you:</div>
<a href="{interest_url}" style="color: #82aaff; font-size: 14px;">{interest_url}</a>
</div>
"""
interest_section_plain = f"""
{interested_count} {people_word} to chat with you:
{interest_url}
"""
# format html # format html
draft_html = f"<div style='font-family: monospace; white-space: pre-wrap; color: #e0e0e0; background: #1a1a1a; padding: 20px;'>{body}</div>{SIGNATURE_HTML}" draft_html = f"<div style='font-family: monospace; white-space: pre-wrap; color: #e0e0e0; background: #1a1a1a; padding: 20px;'>{body}</div>{profile_section_html}{interest_section_html}{SIGNATURE_HTML}"
draft_plain = body + SIGNATURE_PLAINTEXT draft_plain = body + profile_section_plain + interest_section_plain + SIGNATURE_PLAINTEXT
return { return {
'subject': subject, 'subject': subject,

689
profile_page.py Normal file
View file

@ -0,0 +1,689 @@
#!/usr/bin/env python3
"""
profile page template and helpers for connectd
comprehensive "get to know" page showing ALL data
"""
import json
from urllib.parse import quote
PROFILE_HTML = """<!DOCTYPE html>
<html>
<head>
<title>{name} | connectd</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
background: #0a0a0f;
color: #e0e0e0;
line-height: 1.6;
}}
.container {{ max-width: 900px; margin: 0 auto; padding: 20px; }}
/* header */
.header {{
display: flex;
gap: 24px;
align-items: flex-start;
padding: 30px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 12px;
margin-bottom: 24px;
border: 1px solid #333;
}}
.avatar {{
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, #c792ea 0%, #82aaff 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: #0a0a0f;
font-weight: bold;
flex-shrink: 0;
}}
.avatar img {{ width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }}
.header-info {{ flex: 1; }}
.name {{ font-size: 2em; color: #c792ea; margin-bottom: 4px; }}
.username {{ color: #82aaff; font-size: 1.1em; margin-bottom: 8px; }}
.location {{ color: #0f8; margin-bottom: 8px; }}
.pronouns {{
display: inline-block;
background: #2d3a4a;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.85em;
color: #f7c;
}}
.score-badge {{
display: inline-block;
background: linear-gradient(135deg, #c792ea 0%, #f7c 100%);
color: #0a0a0f;
padding: 4px 12px;
border-radius: 20px;
font-weight: bold;
margin-left: 12px;
}}
.user-type {{
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.85em;
margin-left: 8px;
}}
.user-type.builder {{ background: #2d4a2d; color: #8f8; }}
.user-type.lost {{ background: #4a2d2d; color: #f88; }}
.user-type.none {{ background: #333; color: #888; }}
/* bio section */
.bio {{
background: #1a1a2e;
padding: 24px;
border-radius: 12px;
margin-bottom: 24px;
border: 1px solid #333;
font-size: 1.1em;
color: #ddd;
font-style: italic;
}}
.bio:empty {{ display: none; }}
/* sections */
.section {{
background: #1a1a2e;
border-radius: 12px;
margin-bottom: 20px;
border: 1px solid #333;
overflow: hidden;
}}
.section-header {{
background: #2a2a4e;
padding: 14px 20px;
color: #82aaff;
font-size: 1.1em;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}}
.section-header:hover {{ background: #3a3a5e; }}
.section-header .toggle {{ color: #666; }}
.section-content {{ padding: 20px; }}
.section-content.collapsed {{ display: none; }}
/* platforms/handles */
.platforms {{
display: flex;
flex-wrap: wrap;
gap: 12px;
}}
.platform {{
display: flex;
align-items: center;
gap: 8px;
background: #0d0d15;
padding: 10px 16px;
border-radius: 8px;
border: 1px solid #333;
}}
.platform:hover {{ border-color: #0f8; }}
.platform-icon {{ font-size: 1.2em; }}
.platform a {{ color: #82aaff; text-decoration: none; }}
.platform a:hover {{ color: #0f8; }}
.platform-main {{ color: #c792ea; font-weight: bold; }}
/* signals/tags */
.tags {{
display: flex;
flex-wrap: wrap;
gap: 8px;
}}
.tag {{
background: #2d3a4a;
color: #82aaff;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.9em;
cursor: pointer;
transition: all 0.2s;
}}
.tag:hover {{ background: #3d4a5a; transform: scale(1.05); }}
.tag.positive {{ background: #2d4a2d; color: #8f8; }}
.tag.negative {{ background: #4a2d2d; color: #f88; }}
.tag.rare {{ background: linear-gradient(135deg, #c792ea 0%, #f7c 100%); color: #0a0a0f; }}
.tag-detail {{
display: none;
background: #0d0d15;
padding: 10px;
border-radius: 6px;
margin-top: 8px;
font-size: 0.85em;
color: #888;
}}
/* repos */
.repos {{ display: flex; flex-direction: column; gap: 12px; }}
.repo {{
background: #0d0d15;
padding: 16px;
border-radius: 8px;
border: 1px solid #333;
}}
.repo:hover {{ border-color: #c792ea; }}
.repo-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }}
.repo-name {{ color: #c792ea; font-weight: bold; }}
.repo-name a {{ color: #c792ea; text-decoration: none; }}
.repo-name a:hover {{ color: #f7c; }}
.repo-stats {{ display: flex; gap: 16px; }}
.repo-stat {{ color: #888; font-size: 0.85em; }}
.repo-stat .star {{ color: #ffd700; }}
.repo-desc {{ color: #aaa; font-size: 0.9em; }}
.repo-lang {{
display: inline-block;
background: #333;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8em;
color: #0f8;
}}
/* languages */
.languages {{ display: flex; flex-wrap: wrap; gap: 8px; }}
.lang {{
background: #0d0d15;
padding: 8px 14px;
border-radius: 6px;
border: 1px solid #333;
}}
.lang-name {{ color: #0f8; }}
.lang-count {{ color: #666; font-size: 0.85em; margin-left: 6px; }}
/* subreddits */
.subreddits {{ display: flex; flex-wrap: wrap; gap: 8px; }}
.subreddit {{
background: #ff4500;
color: white;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.9em;
}}
.subreddit a {{ color: white; text-decoration: none; }}
/* matches */
.match-summary {{
display: flex;
gap: 20px;
flex-wrap: wrap;
}}
.match-stat {{
background: #0d0d15;
padding: 16px 24px;
border-radius: 8px;
text-align: center;
}}
.match-stat b {{ font-size: 2em; color: #c792ea; display: block; }}
.match-stat small {{ color: #666; }}
/* raw data */
.raw-data {{
background: #0d0d15;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-size: 0.85em;
color: #888;
}}
pre {{ white-space: pre-wrap; word-break: break-all; }}
/* contact */
.contact-methods {{ display: flex; flex-direction: column; gap: 12px; }}
.contact-method {{
display: flex;
align-items: center;
gap: 12px;
background: #0d0d15;
padding: 14px 20px;
border-radius: 8px;
border: 1px solid #333;
}}
.contact-method.preferred {{ border-color: #0f8; background: #1a2a1a; }}
.contact-method a {{ color: #82aaff; text-decoration: none; }}
.contact-method a:hover {{ color: #0f8; }}
/* reasons */
.reasons {{ display: flex; flex-direction: column; gap: 8px; }}
.reason {{
background: #0d0d15;
padding: 10px 14px;
border-radius: 6px;
color: #aaa;
font-size: 0.9em;
border-left: 3px solid #c792ea;
}}
/* back link */
.back {{
display: inline-block;
color: #666;
text-decoration: none;
margin-bottom: 20px;
}}
.back:hover {{ color: #0f8; }}
/* footer */
.footer {{
text-align: center;
padding: 30px;
color: #444;
font-size: 0.85em;
}}
.footer a {{ color: #666; }}
/* responsive */
@media (max-width: 600px) {{
.header {{ flex-direction: column; align-items: center; text-align: center; }}
.avatar {{ width: 100px; height: 100px; }}
.name {{ font-size: 1.5em; }}
}}
</style>
</head>
<body>
<div class="container">
<a href="/" class="back"> back to dashboard</a>
<!-- HEADER -->
<div class="header">
<div class="avatar">{avatar}</div>
<div class="header-info">
<div class="name">
{name}
<span class="score-badge">{score}</span>
<span class="user-type {user_type_class}">{user_type}</span>
</div>
<div class="username">@{username} on {platform}</div>
{location_html}
{pronouns_html}
</div>
</div>
<!-- BIO -->
<div class="bio">{bio}</div>
<!-- WHERE TO FIND THEM -->
<div class="section">
<div class="section-header" onclick="toggleSection(this)">
<span>🌐 where to find them</span>
<span class="toggle"></span>
</div>
<div class="section-content">
<div class="platforms">
{platforms_html}
</div>
</div>
</div>
<!-- WHAT THEY BUILD -->
{repos_section}
<!-- WHAT THEY CARE ABOUT -->
<div class="section">
<div class="section-header" onclick="toggleSection(this)">
<span>💜 what they care about ({signal_count} signals)</span>
<span class="toggle"></span>
</div>
<div class="section-content">
<div class="tags">
{signals_html}
</div>
{negative_signals_html}
</div>
</div>
<!-- WHY THEY SCORED -->
<div class="section">
<div class="section-header" onclick="toggleSection(this)">
<span>📊 why they scored {score}</span>
<span class="toggle"></span>
</div>
<div class="section-content">
<div class="reasons">
{reasons_html}
</div>
</div>
</div>
<!-- COMMUNITIES -->
{communities_section}
<!-- MATCHING -->
<div class="section">
<div class="section-header" onclick="toggleSection(this)">
<span>🤝 in the network</span>
<span class="toggle"></span>
</div>
<div class="section-content">
<div class="match-summary">
<div class="match-stat">
<b>{match_count}</b>
<small>matches</small>
</div>
<div class="match-stat">
<b>{lost_score}</b>
<small>lost potential</small>
</div>
</div>
</div>
</div>
<!-- CONTACT -->
<div class="section">
<div class="section-header" onclick="toggleSection(this)">
<span>📬 how to connect</span>
<span class="toggle"></span>
</div>
<div class="section-content">
{contact_html}
</div>
</div>
<!-- RAW DATA -->
<div class="section">
<div class="section-header" onclick="toggleSection(this)">
<span>🔍 the data (everything connectd knows)</span>
<span class="toggle"></span>
</div>
<div class="section-content collapsed">
<p style="color: #666; margin-bottom: 16px;">
public data is public. this is everything we've gathered from public sources.
</p>
<div class="raw-data">
<pre>{raw_json}</pre>
</div>
</div>
</div>
<div class="footer">
connectd · public data is public ·
<a href="/api/humans/{id}/full">raw json</a>
</div>
</div>
<script>
function toggleSection(header) {{
var content = header.nextElementSibling;
var toggle = header.querySelector('.toggle');
if (content.classList.contains('collapsed')) {{
content.classList.remove('collapsed');
toggle.textContent = '';
}} else {{
content.classList.add('collapsed');
toggle.textContent = '';
}}
}}
</script>
</body>
</html>
"""
RARE_SIGNALS = {'queer', 'solarpunk', 'cooperative', 'intentional_community', 'trans', 'nonbinary'}
def parse_json_field(val):
"""safely parse json string or return as-is"""
if isinstance(val, str):
try:
return json.loads(val)
except:
return val
return val or {}
def render_profile(human, match_count=0):
"""render full profile page for a human"""
# parse json fields
signals = parse_json_field(human.get('signals', '[]'))
if isinstance(signals, str):
signals = []
negative_signals = parse_json_field(human.get('negative_signals', '[]'))
if isinstance(negative_signals, str):
negative_signals = []
reasons = parse_json_field(human.get('reasons', '[]'))
if isinstance(reasons, str):
reasons = []
contact = parse_json_field(human.get('contact', '{}'))
extra = parse_json_field(human.get('extra', '{}'))
# nested extra sometimes
if 'extra' in extra:
extra = {**extra, **parse_json_field(extra['extra'])}
# basic info
name = human.get('name') or human.get('username', 'unknown')
username = human.get('username', 'unknown')
platform = human.get('platform', 'unknown')
bio = human.get('bio', '')
location = human.get('location') or extra.get('location', '')
score = human.get('score', 0)
user_type = human.get('user_type', 'none')
lost_score = human.get('lost_potential_score', 0)
# avatar - first letter or image
avatar_html = name[0].upper() if name else '?'
avatar_url = extra.get('avatar_url') or extra.get('profile_image')
if avatar_url:
avatar_html = f'<img src="{avatar_url}" alt="{name}">'
# location html
location_html = f'<div class="location">📍 {location}</div>' if location else ''
# pronouns - try to detect
pronouns = extra.get('pronouns', '')
if not pronouns and bio:
bio_lower = bio.lower()
if 'she/her' in bio_lower:
pronouns = 'she/her'
elif 'he/him' in bio_lower:
pronouns = 'he/him'
elif 'they/them' in bio_lower:
pronouns = 'they/them'
pronouns_html = f'<span class="pronouns">{pronouns}</span>' if pronouns else ''
# platforms/handles
handles = extra.get('handles', {})
platforms_html = []
# main platform
if platform == 'github':
platforms_html.append(f'<div class="platform platform-main"><span class="platform-icon">💻</span><a href="https://github.com/{username}" target="_blank">github.com/{username}</a></div>')
elif platform == 'reddit':
platforms_html.append(f'<div class="platform platform-main"><span class="platform-icon">🔴</span><a href="https://reddit.com/u/{username}" target="_blank">u/{username}</a></div>')
elif platform == 'mastodon':
instance = human.get('instance', 'mastodon.social')
platforms_html.append(f'<div class="platform platform-main"><span class="platform-icon">🐘</span><a href="https://{instance}/@{username}" target="_blank">@{username}@{instance}</a></div>')
elif platform == 'lobsters':
platforms_html.append(f'<div class="platform platform-main"><span class="platform-icon">🦞</span><a href="https://lobste.rs/u/{username}" target="_blank">lobste.rs/u/{username}</a></div>')
# other handles
if handles.get('github') and platform != 'github':
platforms_html.append(f'<div class="platform"><span class="platform-icon">💻</span><a href="https://github.com/{handles["github"]}" target="_blank">github.com/{handles["github"]}</a></div>')
if handles.get('twitter'):
t = handles['twitter'].lstrip('@')
platforms_html.append(f'<div class="platform"><span class="platform-icon">🐦</span><a href="https://twitter.com/{t}" target="_blank">@{t}</a></div>')
if handles.get('mastodon') and platform != 'mastodon':
platforms_html.append(f'<div class="platform"><span class="platform-icon">🐘</span>{handles["mastodon"]}</div>')
if handles.get('bluesky'):
platforms_html.append(f'<div class="platform"><span class="platform-icon">🦋</span>{handles["bluesky"]}</div>')
if handles.get('linkedin'):
platforms_html.append(f'<div class="platform"><span class="platform-icon">💼</span><a href="https://linkedin.com/in/{handles["linkedin"]}" target="_blank">linkedin</a></div>')
if handles.get('matrix'):
platforms_html.append(f'<div class="platform"><span class="platform-icon">💬</span>{handles["matrix"]}</div>')
# contact methods
if contact.get('blog'):
platforms_html.append(f'<div class="platform"><span class="platform-icon">🌐</span><a href="{contact["blog"]}" target="_blank">{contact["blog"]}</a></div>')
# signals html
signals_html = []
for sig in signals:
cls = 'tag'
if sig in RARE_SIGNALS:
cls = 'tag rare'
signals_html.append(f'<span class="{cls}">{sig}</span>')
# negative signals
negative_signals_html = ''
if negative_signals:
neg_tags = ' '.join([f'<span class="tag negative">{s}</span>' for s in negative_signals])
negative_signals_html = f'<div style="margin-top: 16px;"><small style="color: #666;">negative signals:</small><br><div class="tags" style="margin-top: 8px;">{neg_tags}</div></div>'
# reasons html
reasons_html = '\n'.join([f'<div class="reason">{r}</div>' for r in reasons]) if reasons else '<div class="reason">no specific reasons recorded</div>'
# repos section
repos_section = ''
top_repos = extra.get('top_repos', [])
languages = extra.get('languages', {})
repo_count = extra.get('repo_count', 0)
total_stars = extra.get('total_stars', 0)
if top_repos or languages:
repos_html = ''
if top_repos:
for repo in top_repos[:6]:
repo_name = repo.get('name', 'unknown')
repo_desc = repo.get('description', '')[:200] or 'no description'
repo_stars = repo.get('stars', 0)
repo_lang = repo.get('language', '')
lang_badge = f'<span class="repo-lang">{repo_lang}</span>' if repo_lang else ''
repos_html += f'''
<div class="repo">
<div class="repo-header">
<span class="repo-name"><a href="https://github.com/{username}/{repo_name}" target="_blank">{repo_name}</a></span>
<div class="repo-stats">
<span class="repo-stat"><span class="star"></span> {repo_stars:,}</span>
{lang_badge}
</div>
</div>
<div class="repo-desc">{repo_desc}</div>
</div>
'''
# languages
langs_html = ''
if languages:
sorted_langs = sorted(languages.items(), key=lambda x: x[1], reverse=True)[:10]
for lang, count in sorted_langs:
langs_html += f'<div class="lang"><span class="lang-name">{lang}</span><span class="lang-count">×{count}</span></div>'
repos_section = f'''
<div class="section">
<div class="section-header" onclick="toggleSection(this)">
<span>🔨 what they build ({repo_count} repos, {total_stars:,} )</span>
<span class="toggle"></span>
</div>
<div class="section-content">
<div class="languages" style="margin-bottom: 16px;">
{langs_html}
</div>
<div class="repos">
{repos_html}
</div>
</div>
</div>
'''
# communities section (subreddits, etc)
communities_section = ''
subreddits = extra.get('subreddits', [])
topics = extra.get('topics', [])
if subreddits or topics:
subs_html = ''
if subreddits:
subs_html = '<div style="margin-bottom: 16px;"><small style="color: #666;">subreddits:</small><div class="subreddits" style="margin-top: 8px;">'
for sub in subreddits:
subs_html += f'<span class="subreddit"><a href="https://reddit.com/r/{sub}" target="_blank">r/{sub}</a></span>'
subs_html += '</div></div>'
topics_html = ''
if topics:
topics_html = '<div><small style="color: #666;">topics:</small><div class="tags" style="margin-top: 8px;">'
for topic in topics:
topics_html += f'<span class="tag">{topic}</span>'
topics_html += '</div></div>'
communities_section = f'''
<div class="section">
<div class="section-header" onclick="toggleSection(this)">
<span>👥 communities</span>
<span class="toggle"></span>
</div>
<div class="section-content">
{subs_html}
{topics_html}
</div>
</div>
'''
# contact section
contact_html = '<div class="contact-methods">'
emails = contact.get('emails', [])
if contact.get('email') and contact['email'] not in emails:
emails = [contact['email']] + emails
if emails:
for i, email in enumerate(emails[:3]):
preferred = 'preferred' if i == 0 else ''
contact_html += f'<div class="contact-method {preferred}"><span>📧</span><a href="mailto:{email}">{email}</a></div>'
if contact.get('mastodon'):
contact_html += f'<div class="contact-method"><span>🐘</span>{contact["mastodon"]}</div>'
if contact.get('matrix'):
contact_html += f'<div class="contact-method"><span>💬</span>{contact["matrix"]}</div>'
if contact.get('twitter'):
contact_html += f'<div class="contact-method"><span>🐦</span>@{contact["twitter"]}</div>'
if not emails and not contact.get('mastodon') and not contact.get('matrix'):
contact_html += '<div class="contact-method">no contact methods discovered</div>'
contact_html += '</div>'
# raw json
raw_json = json.dumps(human, indent=2, default=str)
# render
return PROFILE_HTML.format(
name=name,
username=username,
platform=platform,
bio=bio,
score=int(score),
user_type=user_type,
user_type_class=user_type,
avatar=avatar_html,
location_html=location_html,
pronouns_html=pronouns_html,
platforms_html='\n'.join(platforms_html),
signals_html='\n'.join(signals_html),
signal_count=len(signals),
negative_signals_html=negative_signals_html,
reasons_html=reasons_html,
repos_section=repos_section,
communities_section=communities_section,
match_count=match_count,
lost_score=int(lost_score),
contact_html=contact_html,
raw_json=raw_json,
id=human.get('id', 0)
)