mirror of
https://github.com/sudoxnym/connectd.git
synced 2026-04-14 03:27:24 +00:00
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:
parent
843d1b199d
commit
0a94f880b5
7 changed files with 1395 additions and 460 deletions
439
api.py
439
api.py
|
|
@ -12,14 +12,22 @@ 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
|
||||
from central_client import CentralClient, get_client
|
||||
from profile_page import render_profile
|
||||
|
||||
# central API config
|
||||
import requests
|
||||
CENTRAL_API = os.environ.get('CONNECTD_CENTRAL_API', '')
|
||||
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))
|
||||
|
||||
# shared state (updated by daemon)
|
||||
|
|
@ -180,31 +188,18 @@ async function loadStats() {
|
|||
uptime = hrs + 'h ' + mins + 'm';
|
||||
}
|
||||
|
||||
$('status').innerHTML = 'daemon <b>' + (h.running ? 'ON' : 'OFF') + '</b> | ' + uptime + ' | ' + h.intros_today + ' today';
|
||||
|
||||
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>';
|
||||
}
|
||||
$('status').innerHTML = 'daemon <b>' + (h.running ? 'ON' : 'OFF') + '</b> | ' + uptime + ' | ' + (h.intros_today || 0) + ' today | ' + (s.active_instances || 1) + ' instances';
|
||||
|
||||
$('stats').innerHTML =
|
||||
'<div style="color:#666;font-size:0.8em;margin-bottom:8px">// local</div>' +
|
||||
'<div class="stats">' +
|
||||
'<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>' +
|
||||
'</div>' + centralHtml;
|
||||
'<div class="stat"><b>' + (s.total_humans || 0) + '</b><small>humans</small></div>' +
|
||||
'<div class="stat"><b>' + (s.total_matches || 0).toLocaleString() + '</b><small>matches</small></div>' +
|
||||
'<div class="stat"><b>' + (s.lost_builders || 0) + '</b><small>lost</small></div>' +
|
||||
'<div class="stat"><b>' + (s.builders || 0) + '</b><small>builders</small></div>' +
|
||||
'<div class="stat"><b>' + (h.score_90_plus || 0) + '</b><small>90+</small></div>' +
|
||||
'<div class="stat"><b>' + (h.score_80_89 || 0) + '</b><small>80+</small></div>' +
|
||||
'<div class="stat"><b>' + (s.intros_sent || 0) + '</b><small>sent</small></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
async function loadHost() {
|
||||
|
|
@ -343,28 +338,28 @@ async function loadFailed() {
|
|||
$('failed').innerHTML = html;
|
||||
}
|
||||
async function loadLost() {
|
||||
var res = await fetch("/api/lost_builders");
|
||||
var res = await fetch('/api/lost_builders');
|
||||
var data = await res.json();
|
||||
|
||||
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>';
|
||||
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>';
|
||||
|
||||
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++) {
|
||||
var m = data.matches[i];
|
||||
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=\"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\">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="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="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">score: ' + m.builder_score + ' | repos: ' + m.builder_repos + ' | stars: ' + m.builder_stars + '</div>';
|
||||
html += '<div class="meta">shared: ' + (m.shared || []).join(', ') + '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
$("lost").innerHTML = html;
|
||||
$('lost').innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -489,6 +484,13 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||
self._handle_user()
|
||||
elif path == '/api/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:
|
||||
self._send_json({'error': 'not found'}, 404)
|
||||
def _handle_favicon(self):
|
||||
|
|
@ -510,52 +512,44 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||
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()
|
||||
try:
|
||||
central = get_central()
|
||||
history = central.get_outreach_history(status='sent', limit=20)
|
||||
sent = [{'recipient_id': h.get('human_id'), 'method': h.get('sent_via', 'unknown'),
|
||||
'draft': h.get('draft', ''), 'timestamp': h.get('completed_at', '')}
|
||||
for h in history]
|
||||
self._send_json({"sent": sent})
|
||||
except Exception as e:
|
||||
self._send_json({"sent": [], "error": str(e)})
|
||||
|
||||
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", [])
|
||||
try:
|
||||
central = get_central()
|
||||
history = central.get_outreach_history(status='failed', limit=50)
|
||||
failed = [{'recipient_id': h.get('human_id'), 'error': h.get('error', 'unknown'),
|
||||
'timestamp': h.get('completed_at', '')}
|
||||
for h in history]
|
||||
self._send_json({"failed": failed})
|
||||
except Exception as e:
|
||||
self._send_json({"failed": [], "error": str(e)})
|
||||
|
||||
def _handle_host(self):
|
||||
"""daemon status and match stats"""
|
||||
import sqlite3
|
||||
"""daemon status and match stats from central"""
|
||||
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()
|
||||
central = get_central()
|
||||
stats = central.get_stats()
|
||||
total = stats.get('total_matches', 0)
|
||||
sent = stats.get('intros_sent', 0)
|
||||
pending = stats.get('pending_outreach', 0)
|
||||
# score distribution - sample high scores only
|
||||
high_matches = central.get_matches(min_score=60, limit=5000)
|
||||
s90 = sum(1 for m in high_matches if m.get('overlap_score', 0) >= 90)
|
||||
s80 = sum(1 for m in high_matches if 80 <= m.get('overlap_score', 0) < 90)
|
||||
s70 = sum(1 for m in high_matches if 70 <= m.get('overlap_score', 0) < 80)
|
||||
s60 = sum(1 for m in high_matches if 60 <= m.get('overlap_score', 0) < 70)
|
||||
except:
|
||||
pending = sent = rejected = total = s90 = s80 = s70 = s60 = 0
|
||||
pending = sent = total = s90 = s80 = s70 = s60 = 0
|
||||
uptime = None
|
||||
if state.get('started_at'):
|
||||
try:
|
||||
|
|
@ -565,7 +559,7 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||
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,
|
||||
'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,
|
||||
})
|
||||
|
||||
|
|
@ -720,10 +714,8 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||
self._send_json({'error': str(e)}, 500)
|
||||
|
||||
def _handle_host_matches(self):
|
||||
"""matches for priority user"""
|
||||
import sqlite3
|
||||
"""top matches from central"""
|
||||
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('&'):
|
||||
|
|
@ -731,28 +723,21 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||
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))
|
||||
central = get_central()
|
||||
raw_matches = central.get_matches(min_score=60, limit=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})
|
||||
for m in raw_matches:
|
||||
reasons = j.loads(m.get('overlap_reasons') or '[]') if isinstance(m.get('overlap_reasons'), str) else (m.get('overlap_reasons') or [])
|
||||
matches.append({
|
||||
'id': m.get('id'),
|
||||
'score': int(m.get('overlap_score', 0)),
|
||||
'reasons': reasons[:3] if isinstance(reasons, list) else [],
|
||||
'status': 'pending',
|
||||
'other_user': m.get('human_b_username'),
|
||||
'other_platform': m.get('human_b_platform'),
|
||||
'contact': ''
|
||||
})
|
||||
self._send_json({'host': 'central', 'matches': matches})
|
||||
except Exception as e:
|
||||
self._send_json({'error': str(e)}, 500)
|
||||
|
||||
|
|
@ -1009,8 +994,7 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||
self._send_json({'error': str(e)}, 500)
|
||||
|
||||
def _handle_pending_matches(self):
|
||||
"""pending matches - returns BOTH directions for each match"""
|
||||
import sqlite3
|
||||
"""pending matches from central - returns BOTH directions for each match"""
|
||||
import json as j
|
||||
limit = 30
|
||||
if '?' in self.path:
|
||||
|
|
@ -1019,60 +1003,40 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||
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,))
|
||||
central = get_central()
|
||||
raw_matches = central.get_matches(min_score=60, 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 []
|
||||
for m in raw_matches:
|
||||
reasons = j.loads(m.get('overlap_reasons') or '[]') if isinstance(m.get('overlap_reasons'), str) else (m.get('overlap_reasons') or [])
|
||||
# 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 ''})
|
||||
matches.append({
|
||||
'id': m.get('id'),
|
||||
'to_user': m.get('human_a_username'),
|
||||
'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
|
||||
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()
|
||||
matches.append({
|
||||
'id': m.get('id'),
|
||||
'to_user': m.get('human_b_username'),
|
||||
'about_user': m.get('human_a_username'),
|
||||
'score': int(m.get('overlap_score', 0)),
|
||||
'reasons': reasons[:3] if isinstance(reasons, list) else [],
|
||||
'method': 'pending',
|
||||
'contact': ''
|
||||
})
|
||||
self._send_json({'matches': matches})
|
||||
except Exception as e:
|
||||
self._send_json({'error': str(e)}, 500)
|
||||
|
||||
def _handle_stats(self):
|
||||
"""return database statistics (local + central)"""
|
||||
"""return database statistics from central"""
|
||||
try:
|
||||
db = Database()
|
||||
stats = db.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)}
|
||||
|
||||
central = get_central()
|
||||
stats = central.get_stats()
|
||||
self._send_json(stats)
|
||||
except Exception as e:
|
||||
self._send_json({'error': str(e)}, 500)
|
||||
|
|
@ -1243,49 +1207,190 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||
|
||||
|
||||
def _handle_lost_builders(self):
|
||||
"""return lost builders with their inspiring matches"""
|
||||
"""return lost builders from central"""
|
||||
try:
|
||||
from matchd.lost import find_matches_for_lost_builders
|
||||
db = Database()
|
||||
matches, error = find_matches_for_lost_builders(db, min_lost_score=30, min_values_score=15, limit=50)
|
||||
central = get_central()
|
||||
lost = central.get_lost_builders(min_score=30, limit=50)
|
||||
builders = central.get_builders(min_score=50, limit=20)
|
||||
|
||||
# simple matching: pair lost builders with builders who have similar signals
|
||||
result = {
|
||||
'total': len(matches) if matches else 0,
|
||||
'error': error,
|
||||
'total': len(lost),
|
||||
'error': None if builders else 'no active builders available',
|
||||
'matches': []
|
||||
}
|
||||
|
||||
if matches:
|
||||
for m in matches:
|
||||
lost = m.get('lost_user', {})
|
||||
builder = m.get('inspiring_builder', {})
|
||||
if lost and builders:
|
||||
for l in lost[:50]:
|
||||
# find best matching builder
|
||||
lost_signals = set(json.loads(l.get('signals') or '[]'))
|
||||
best_builder = None
|
||||
best_score = 0
|
||||
|
||||
for b in builders:
|
||||
builder_signals = set(json.loads(b.get('signals') or '[]'))
|
||||
shared = lost_signals & builder_signals
|
||||
score = len(shared) * 10 + b.get('score', 0) * 0.1
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_builder = b
|
||||
best_shared = list(shared)
|
||||
|
||||
if best_builder:
|
||||
extra = json.loads(best_builder.get('extra') or '{}')
|
||||
result['matches'].append({
|
||||
'lost_user': lost.get('username'),
|
||||
'lost_platform': lost.get('platform'),
|
||||
'lost_score': lost.get('lost_potential_score', 0),
|
||||
'values_score': lost.get('score', 0),
|
||||
'builder': builder.get('username'),
|
||||
'builder_platform': builder.get('platform'),
|
||||
'builder_score': builder.get('score', 0),
|
||||
'builder_repos': m.get('builder_repos', 0),
|
||||
'builder_stars': m.get('builder_stars', 0),
|
||||
'match_score': m.get('match_score', 0),
|
||||
'shared': m.get('shared_interests', [])[:5],
|
||||
'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)
|
||||
except Exception as e:
|
||||
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():
|
||||
"""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)
|
||||
|
|
|
|||
|
|
@ -178,6 +178,27 @@ class CentralClient:
|
|||
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
|
||||
def get_client() -> CentralClient:
|
||||
return CentralClient()
|
||||
|
|
|
|||
555
daemon.py
555
daemon.py
|
|
@ -1,12 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
connectd daemon - continuous discovery and matchmaking
|
||||
|
||||
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
|
||||
REWIRED TO USE CENTRAL DATABASE
|
||||
"""
|
||||
|
||||
import time
|
||||
|
|
@ -19,7 +14,7 @@ from pathlib import Path
|
|||
|
||||
from db import Database
|
||||
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.forges import scrape_all_forges
|
||||
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 config import get_lost_config
|
||||
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
|
||||
SCOUT_INTERVAL = 3600 * 4 # full scout every 4 hours
|
||||
MATCH_INTERVAL = 3600 # check matches every hour
|
||||
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
|
||||
|
||||
# central coordination (optional - for distributed instances)
|
||||
try:
|
||||
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
|
||||
MIN_OVERLAP_PRIORITY = 30
|
||||
MIN_OVERLAP_STRANGERS = 50
|
||||
|
||||
|
||||
class ConnectDaemon:
|
||||
def __init__(self, dry_run=False):
|
||||
self.db = Database()
|
||||
init_users_table(self.db.conn)
|
||||
purged = self.db.purge_disqualified()
|
||||
if any(purged.values()):
|
||||
self.log(f"purged disqualified: {purged}")
|
||||
# local db only for priority_users (host-specific)
|
||||
self.local_db = Database()
|
||||
init_users_table(self.local_db.conn)
|
||||
|
||||
# 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.dry_run = dry_run
|
||||
self.started_at = datetime.now()
|
||||
|
|
@ -69,30 +73,19 @@ class ConnectDaemon:
|
|||
self.last_lost = None
|
||||
self.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()
|
||||
|
||||
# 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.SIGTERM, self._shutdown)
|
||||
|
||||
# auto-discover host user from env
|
||||
if 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()
|
||||
|
||||
def _shutdown(self, signum, frame):
|
||||
|
|
@ -101,10 +94,8 @@ class ConnectDaemon:
|
|||
self._update_api_state()
|
||||
|
||||
def _update_api_state(self):
|
||||
"""update API state for HA integration"""
|
||||
now = datetime.now()
|
||||
|
||||
# calculate countdowns - if no cycle has run, use started_at
|
||||
def secs_until(last, interval):
|
||||
base = last if last else self.started_at
|
||||
next_run = base + timedelta(seconds=interval)
|
||||
|
|
@ -128,123 +119,116 @@ class ConnectDaemon:
|
|||
})
|
||||
|
||||
def log(self, msg):
|
||||
"""timestamped log"""
|
||||
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}")
|
||||
|
||||
def reset_daily_limits(self):
|
||||
"""reset daily intro count"""
|
||||
if datetime.now().date() != self.today:
|
||||
self.today = datetime.now().date()
|
||||
self.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")
|
||||
|
||||
def scout_cycle(self):
|
||||
"""run discovery on all platforms"""
|
||||
self.log("starting scout cycle...")
|
||||
"""run discovery - scrape to CENTRAL"""
|
||||
self.log("starting scout cycle (-> CENTRAL)...")
|
||||
|
||||
# dummy db - scrapers save here but we push to central
|
||||
dummy_db = DummyDb()
|
||||
scraped_humans = []
|
||||
|
||||
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:
|
||||
self.log(f"github scout error: {e}")
|
||||
|
||||
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:
|
||||
self.log(f"reddit scout error: {e}")
|
||||
|
||||
try:
|
||||
scrape_mastodon(self.db, limit_per_instance=30)
|
||||
|
||||
# scrape self-hosted git forges (highest signal)
|
||||
self.log("scraping self-hosted git forges...")
|
||||
try:
|
||||
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}")
|
||||
from scoutd.mastodon import scrape_mastodon
|
||||
masto_humans = scrape_mastodon(dummy_db, limit_per_instance=30)
|
||||
if masto_humans:
|
||||
scraped_humans.extend(masto_humans)
|
||||
self.log(f" mastodon: {len(masto_humans) if masto_humans else 0} humans")
|
||||
except Exception as e:
|
||||
self.log(f"mastodon scout error: {e}")
|
||||
|
||||
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:
|
||||
self.log(f"lobsters scout error: {e}")
|
||||
|
||||
# push all to central
|
||||
if scraped_humans:
|
||||
self.log(f"pushing {len(scraped_humans)} humans to CENTRAL...")
|
||||
try:
|
||||
scrape_lemmy(self.db, limit_per_community=30)
|
||||
created, updated = self.central.upsert_humans_bulk(scraped_humans)
|
||||
self.log(f" central: {created} created, {updated} updated")
|
||||
except Exception as e:
|
||||
self.log(f"lemmy scout error: {e}")
|
||||
|
||||
try:
|
||||
scrape_discord(self.db, limit_per_channel=50)
|
||||
except Exception as e:
|
||||
self.log(f"discord scout error: {e}")
|
||||
self.log(f" central push error: {e}")
|
||||
|
||||
self.last_scout = datetime.now()
|
||||
stats = self.db.stats()
|
||||
self.log(f"scout complete: {stats['total_humans']} humans in db")
|
||||
stats = self.central.get_stats()
|
||||
self.log(f"scout complete: {stats.get('total_humans', 0)} humans in CENTRAL")
|
||||
|
||||
def match_priority_users(self):
|
||||
"""find matches for priority users (hosts)"""
|
||||
priority_users = get_priority_users(self.db.conn)
|
||||
"""find matches for priority users (hosts) using CENTRAL data"""
|
||||
priority_users = get_priority_users(self.local_db.conn)
|
||||
|
||||
if not priority_users:
|
||||
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:
|
||||
# build priority user's fingerprint from their linked profiles
|
||||
# use stored signals first (from discovery/scoring)
|
||||
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'):
|
||||
puser_text.append(puser['bio'])
|
||||
if puser.get('interests'):
|
||||
# supplement with interests if no signals stored
|
||||
if not puser_signals and puser.get('interests'):
|
||||
interests = json.loads(puser['interests']) if isinstance(puser['interests'], str) else puser['interests']
|
||||
puser_signals.extend(interests)
|
||||
if puser.get('looking_for'):
|
||||
puser_text.append(puser['looking_for'])
|
||||
|
||||
# analyze their linked github if available
|
||||
if puser.get('github'):
|
||||
gh_user = analyze_github_user(puser['github'])
|
||||
if gh_user:
|
||||
puser_signals.extend(gh_user.get('signals', []))
|
||||
if not puser_signals:
|
||||
self.log(f" skipping {puser.get('name')} - no signals")
|
||||
continue
|
||||
|
||||
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
|
||||
for human in humans:
|
||||
# skip if it's their own profile on another platform
|
||||
human_user = human.get('username', '').lower()
|
||||
if puser.get('github') and human_user == puser['github'].lower():
|
||||
continue
|
||||
|
|
@ -253,17 +237,18 @@ class ConnectDaemon:
|
|||
if puser.get('mastodon') and human_user == puser['mastodon'].lower().split('@')[0]:
|
||||
continue
|
||||
|
||||
# calculate overlap
|
||||
human_signals = human.get('signals', [])
|
||||
if isinstance(human_signals, str):
|
||||
try:
|
||||
human_signals = json.loads(human_signals)
|
||||
except:
|
||||
human_signals = []
|
||||
|
||||
shared = set(puser_signals) & set(human_signals)
|
||||
overlap_score = len(shared) * 10
|
||||
|
||||
# location bonus
|
||||
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
|
||||
|
||||
if overlap_score >= MIN_OVERLAP_PRIORITY:
|
||||
|
|
@ -271,33 +256,31 @@ class ConnectDaemon:
|
|||
'overlap_score': overlap_score,
|
||||
'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
|
||||
|
||||
if matches_found:
|
||||
self.log(f" found {matches_found} matches for {puser['name'] or puser['email']}")
|
||||
|
||||
def match_strangers(self):
|
||||
"""find matches between discovered humans (altruistic)"""
|
||||
self.log("matching strangers...")
|
||||
"""find matches between discovered humans - save to CENTRAL"""
|
||||
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:
|
||||
return
|
||||
|
||||
# generate fingerprints
|
||||
fingerprints = {}
|
||||
for human in humans:
|
||||
fp = generate_fingerprint(human)
|
||||
fingerprints[human['id']] = fp
|
||||
|
||||
# find pairs
|
||||
matches_found = 0
|
||||
new_matches = []
|
||||
from itertools import combinations
|
||||
|
||||
for human_a, human_b in combinations(humans, 2):
|
||||
# skip same platform same user
|
||||
if human_a['platform'] == human_b['platform']:
|
||||
if human_a['username'] == human_b['username']:
|
||||
continue
|
||||
|
|
@ -308,71 +291,35 @@ class ConnectDaemon:
|
|||
overlap = find_overlap(human_a, human_b, fp_a, fp_b)
|
||||
|
||||
if overlap and overlap["overlap_score"] >= MIN_OVERLAP_STRANGERS:
|
||||
# save match
|
||||
self.db.save_match(human_a['id'], human_b['id'], overlap)
|
||||
new_matches.append({
|
||||
'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
|
||||
|
||||
if matches_found:
|
||||
self.log(f"found {matches_found} stranger matches")
|
||||
# bulk push to central
|
||||
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()
|
||||
|
||||
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):
|
||||
"""send intros to connect strangers (or preview in dry-run mode)"""
|
||||
"""send intros using CENTRAL data"""
|
||||
self.reset_daily_limits()
|
||||
|
||||
if not self.dry_run and self.intros_today >= MAX_INTROS_PER_DAY:
|
||||
self.log("daily intro limit reached")
|
||||
return
|
||||
|
||||
# get unsent matches
|
||||
c = self.db.conn.cursor()
|
||||
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()
|
||||
# get pending matches from CENTRAL
|
||||
matches = self.central.get_matches(min_score=MIN_OVERLAP_STRANGERS, limit=20)
|
||||
|
||||
if self.dry_run:
|
||||
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:
|
||||
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
|
||||
human_a = {
|
||||
'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'],
|
||||
}
|
||||
if not human_a or not human_b:
|
||||
continue
|
||||
|
||||
match_data = {
|
||||
'id': match['id'],
|
||||
'human_a': human_a,
|
||||
'human_b': human_b,
|
||||
'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)]:
|
||||
contact = recipient.get('contact', {})
|
||||
if isinstance(contact, str):
|
||||
try:
|
||||
contact = json.loads(contact)
|
||||
except:
|
||||
contact = {}
|
||||
|
||||
email = contact.get('email')
|
||||
if not email:
|
||||
continue
|
||||
|
||||
# draft intro
|
||||
intro = draft_intro(match_data, recipient='a' if recipient == human_a else 'b')
|
||||
# check if already contacted
|
||||
if self.central.already_contacted(recipient['id']):
|
||||
continue
|
||||
|
||||
# parse overlap reasons for display
|
||||
reasons = match['overlap_reasons']
|
||||
# get token and interest count for recipient
|
||||
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):
|
||||
try:
|
||||
reasons = json.loads(reasons)
|
||||
except:
|
||||
reasons = []
|
||||
reason_summary = ', '.join(reasons[:3]) if reasons else 'aligned values'
|
||||
|
||||
if self.dry_run:
|
||||
# print preview
|
||||
print("\n" + "=" * 60)
|
||||
print(f"TO: {recipient['username']} ({recipient['platform']})")
|
||||
print(f"EMAIL: {email}")
|
||||
|
|
@ -447,13 +395,11 @@ class ConnectDaemon:
|
|||
print("=" * 60)
|
||||
break
|
||||
else:
|
||||
# claim from central first
|
||||
outreach_id = self.claim_from_central(recipient['id'], match['id'], 'intro')
|
||||
outreach_id = self.central.claim_outreach(recipient['id'], match['id'], 'intro')
|
||||
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
|
||||
|
||||
# actually send
|
||||
success, error = send_email(
|
||||
email,
|
||||
f"connectd: you might want to meet {other['username']}",
|
||||
|
|
@ -463,24 +409,124 @@ class ConnectDaemon:
|
|||
if success:
|
||||
self.log(f"sent intro to {recipient['username']} ({email})")
|
||||
self.intros_today += 1
|
||||
self.complete_on_central(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()
|
||||
self.central.complete_outreach(outreach_id, 'sent', 'email', intro['draft'])
|
||||
break
|
||||
else:
|
||||
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()
|
||||
|
||||
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):
|
||||
"""
|
||||
reach out to lost builders - different tone, lower volume.
|
||||
these people need encouragement, not networking.
|
||||
"""
|
||||
"""reach out to lost builders using CENTRAL data"""
|
||||
self.reset_daily_limits()
|
||||
|
||||
lost_config = get_lost_config()
|
||||
|
|
@ -493,43 +539,60 @@ class ConnectDaemon:
|
|||
self.log("daily lost builder intro limit reached")
|
||||
return
|
||||
|
||||
# find lost builders with matching active builders
|
||||
matches, error = find_matches_for_lost_builders(
|
||||
self.db,
|
||||
min_lost_score=lost_config.get('min_lost_score', 40),
|
||||
min_values_score=lost_config.get('min_values_score', 20),
|
||||
# get lost builders from CENTRAL
|
||||
lost_builders = self.central.get_lost_builders(
|
||||
min_score=lost_config.get('min_lost_score', 40),
|
||||
limit=max_per_day - self.lost_intros_today
|
||||
)
|
||||
|
||||
if error:
|
||||
self.log(f"lost builder matching error: {error}")
|
||||
return
|
||||
# get active builders from CENTRAL
|
||||
builders = self.central.get_builders(min_score=50, limit=100)
|
||||
|
||||
if not matches:
|
||||
self.log("no lost builders ready for outreach")
|
||||
if not lost_builders or not builders:
|
||||
self.log("no lost builders or builders available")
|
||||
return
|
||||
|
||||
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:
|
||||
break
|
||||
|
||||
lost = match['lost_user']
|
||||
builder = match['inspiring_builder']
|
||||
# find matching 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')
|
||||
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, builder, lost_config)
|
||||
draft, draft_error = draft_lost_intro(lost, best_builder, lost_config)
|
||||
|
||||
if draft_error:
|
||||
self.log(f"error drafting lost intro for {lost_name}: {draft_error}")
|
||||
continue
|
||||
|
||||
# determine best contact method (activity-based)
|
||||
method, contact_info = determine_best_contact(lost)
|
||||
|
||||
if self.dry_run:
|
||||
|
|
@ -539,9 +602,7 @@ class ConnectDaemon:
|
|||
print(f"TO: {lost_name} ({lost.get('platform')})")
|
||||
print(f"DELIVERY: {method} → {contact_info}")
|
||||
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"SHARED INTERESTS: {', '.join(match.get('shared_interests', []))}")
|
||||
print("-" * 60)
|
||||
print("MESSAGE:")
|
||||
print(draft)
|
||||
|
|
@ -549,12 +610,11 @@ class ConnectDaemon:
|
|||
print("[DRY RUN - NOT SENT]")
|
||||
print("=" * 60)
|
||||
else:
|
||||
# build match data for unified delivery
|
||||
match_data = {
|
||||
'human_a': builder, # inspiring builder
|
||||
'human_b': lost, # lost builder (recipient)
|
||||
'overlap_score': match.get('match_score', 0),
|
||||
'overlap_reasons': match.get('shared_interests', []),
|
||||
'human_a': best_builder,
|
||||
'human_b': lost,
|
||||
'overlap_score': best_score * 10,
|
||||
'overlap_reasons': [],
|
||||
}
|
||||
|
||||
success, error, delivery_method = deliver_intro(match_data, draft)
|
||||
|
|
@ -562,7 +622,6 @@ class ConnectDaemon:
|
|||
if success:
|
||||
self.log(f"sent lost builder intro to {lost_name} via {delivery_method}")
|
||||
self.lost_intros_today += 1
|
||||
self.db.mark_lost_outreach(lost['id'])
|
||||
else:
|
||||
self.log(f"failed to reach {lost_name} via {delivery_method}: {error}")
|
||||
|
||||
|
|
@ -571,9 +630,8 @@ class ConnectDaemon:
|
|||
|
||||
def run(self):
|
||||
"""main daemon loop"""
|
||||
self.log("connectd daemon starting...")
|
||||
self.log("connectd daemon starting (CENTRAL MODE)...")
|
||||
|
||||
# start API server
|
||||
start_api_thread()
|
||||
self.log("api server started on port 8099")
|
||||
|
||||
|
|
@ -592,36 +650,31 @@ class ConnectDaemon:
|
|||
while self.running:
|
||||
now = datetime.now()
|
||||
|
||||
# scout cycle
|
||||
if not self.last_scout or (now - self.last_scout).seconds >= SCOUT_INTERVAL:
|
||||
self.scout_cycle()
|
||||
self._update_api_state()
|
||||
|
||||
# match cycle
|
||||
if not self.last_match or (now - self.last_match).seconds >= MATCH_INTERVAL:
|
||||
self.match_priority_users()
|
||||
self.match_strangers()
|
||||
self._update_api_state()
|
||||
|
||||
# intro cycle
|
||||
if not self.last_intro or (now - self.last_intro).seconds >= INTRO_INTERVAL:
|
||||
self.send_stranger_intros()
|
||||
self.send_priority_user_intros()
|
||||
self._update_api_state()
|
||||
|
||||
# lost builder cycle
|
||||
if not self.last_lost or (now - self.last_lost).seconds >= LOST_INTERVAL:
|
||||
self.send_lost_builder_intros()
|
||||
self._update_api_state()
|
||||
|
||||
# sleep between checks
|
||||
time.sleep(60)
|
||||
|
||||
self.log("connectd daemon stopped")
|
||||
self.db.close()
|
||||
self.local_db.close()
|
||||
|
||||
|
||||
def run_daemon(dry_run=False):
|
||||
"""entry point"""
|
||||
daemon = ConnectDaemon(dry_run=dry_run)
|
||||
daemon.run()
|
||||
|
||||
|
|
|
|||
16
db/users.py
16
db/users.py
|
|
@ -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):
|
||||
"""get matches for a priority user"""
|
||||
"""get matches for a priority user (humans fetched from CENTRAL separately)"""
|
||||
c = conn.cursor()
|
||||
|
||||
if status:
|
||||
c.execute('''SELECT pm.*, h.* FROM priority_matches pm
|
||||
JOIN humans h ON pm.matched_human_id = h.id
|
||||
WHERE pm.priority_user_id = ? AND pm.status = ?
|
||||
ORDER BY pm.overlap_score DESC
|
||||
c.execute('''SELECT * FROM priority_matches
|
||||
WHERE priority_user_id = ? AND status = ?
|
||||
ORDER BY overlap_score DESC
|
||||
LIMIT ?''', (priority_user_id, status, limit))
|
||||
else:
|
||||
c.execute('''SELECT pm.*, h.* 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
|
||||
c.execute('''SELECT * FROM priority_matches
|
||||
WHERE priority_user_id = ?
|
||||
ORDER BY overlap_score DESC
|
||||
LIMIT ?''', (priority_user_id, limit))
|
||||
|
||||
return [dict(row) for row in c.fetchall()]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
"""
|
||||
introd/draft.py - AI writes intro messages referencing both parties' work
|
||||
now with interest system links
|
||||
"""
|
||||
|
||||
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},
|
||||
|
||||
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.
|
||||
|
||||
their work: {other_url}
|
||||
their profile: {profile_url}
|
||||
{interested_line}
|
||||
|
||||
no pitch. just connection. ignore if not useful.
|
||||
|
||||
|
|
@ -32,7 +37,7 @@ you: {recipient_summary}
|
|||
|
||||
overlap: {overlap_summary}
|
||||
|
||||
their work: {other_url}
|
||||
their profile: {profile_url}
|
||||
|
||||
no pitch, just connection.
|
||||
"""
|
||||
|
|
@ -51,12 +56,18 @@ def summarize_human(human_data):
|
|||
# signals/interests
|
||||
signals = human_data.get('signals', [])
|
||||
if isinstance(signals, str):
|
||||
try:
|
||||
signals = json.loads(signals)
|
||||
except:
|
||||
signals = []
|
||||
|
||||
# extra data
|
||||
extra = human_data.get('extra', {})
|
||||
if isinstance(extra, str):
|
||||
try:
|
||||
extra = json.loads(extra)
|
||||
except:
|
||||
extra = {}
|
||||
|
||||
# build summary based on available data
|
||||
topics = extra.get('topics', [])
|
||||
|
|
@ -103,7 +114,10 @@ def summarize_overlap(overlap_data):
|
|||
"""generate overlap summary"""
|
||||
reasons = overlap_data.get('overlap_reasons', [])
|
||||
if isinstance(reasons, str):
|
||||
try:
|
||||
reasons = json.loads(reasons)
|
||||
except:
|
||||
reasons = []
|
||||
|
||||
if reasons:
|
||||
return ' | '.join(reasons[:3])
|
||||
|
|
@ -116,12 +130,14 @@ def summarize_overlap(overlap_data):
|
|||
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
|
||||
|
||||
match_data: dict with human_a, human_b, overlap info
|
||||
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
|
||||
"""
|
||||
|
|
@ -135,19 +151,37 @@ def draft_intro(match_data, recipient='a'):
|
|||
# get names
|
||||
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_username = other_human.get('username', '')
|
||||
|
||||
# generate summaries
|
||||
recipient_summary = summarize_human(recipient_human)
|
||||
other_summary = summarize_human(other_human)
|
||||
overlap_summary = summarize_overlap(match_data)
|
||||
|
||||
# other's url
|
||||
other_url = other_human.get('url', '')
|
||||
# build profile URL with token if available
|
||||
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
|
||||
contact = recipient_human.get('contact', {})
|
||||
if isinstance(contact, str):
|
||||
try:
|
||||
contact = json.loads(contact)
|
||||
except:
|
||||
contact = {}
|
||||
|
||||
channel = None
|
||||
channel_address = None
|
||||
|
|
@ -156,15 +190,12 @@ def draft_intro(match_data, recipient='a'):
|
|||
if contact.get('email'):
|
||||
channel = 'email'
|
||||
channel_address = contact['email']
|
||||
# github issue/discussion
|
||||
elif recipient_human.get('platform') == 'github':
|
||||
channel = 'github'
|
||||
channel_address = recipient_human.get('url')
|
||||
# mastodon DM
|
||||
elif recipient_human.get('platform') == 'mastodon':
|
||||
channel = 'mastodon'
|
||||
channel_address = recipient_human.get('username')
|
||||
# reddit message
|
||||
elif recipient_human.get('platform') == 'reddit':
|
||||
channel = 'reddit'
|
||||
channel_address = recipient_human.get('username')
|
||||
|
|
@ -180,12 +211,13 @@ def draft_intro(match_data, recipient='a'):
|
|||
|
||||
# render draft
|
||||
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,
|
||||
other_name=other_name.split()[0] if other_name else 'someone',
|
||||
other_summary=other_summary,
|
||||
overlap_summary=overlap_summary,
|
||||
other_url=other_url,
|
||||
profile_url=profile_url,
|
||||
interested_line=interested_line,
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
@ -196,15 +228,16 @@ def draft_intro(match_data, recipient='a'):
|
|||
'draft': draft,
|
||||
'overlap_score': match_data.get('overlap_score', 0),
|
||||
'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
|
||||
returns list of two intro dicts
|
||||
"""
|
||||
intro_a = draft_intro(match_data, recipient='a')
|
||||
intro_b = draft_intro(match_data, recipient='b')
|
||||
intro_a = draft_intro(match_data, recipient='a', recipient_token=token_a, interested_count=interested_a)
|
||||
intro_b = draft_intro(match_data, recipient='b', recipient_token=token_b, interested_count=interested_b)
|
||||
|
||||
return [intro_a, intro_b]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -219,9 +219,45 @@ return ONLY the subject line."""
|
|||
|
||||
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
|
||||
draft_html = f"<div style='font-family: monospace; white-space: pre-wrap; color: #e0e0e0; background: #1a1a1a; padding: 20px;'>{body}</div>{SIGNATURE_HTML}"
|
||||
draft_plain = body + SIGNATURE_PLAINTEXT
|
||||
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 + profile_section_plain + interest_section_plain + SIGNATURE_PLAINTEXT
|
||||
|
||||
return {
|
||||
'subject': subject,
|
||||
|
|
|
|||
689
profile_page.py
Normal file
689
profile_page.py
Normal 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)
|
||||
)
|
||||
Loading…
Reference in a new issue