mirror of
https://github.com/sudoxnym/connectd.git
synced 2026-04-14 19:46:30 +00:00
find isolated builders with aligned values and connect them. also finds lost builders - people with potential who haven't started yet. features: - multi-platform discovery (github, reddit, mastodon, lemmy, discord, etc) - values-based matching - lost builder detection and outreach - LLM-powered personalized intros - multi-channel delivery (email, mastodon, bluesky, matrix, discord, github) - fully autonomous daemon mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
497 lines
18 KiB
Python
497 lines
18 KiB
Python
#!/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
|
|
"""
|
|
|
|
import time
|
|
import json
|
|
import signal
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
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)
|
|
from scoutd import scrape_github, scrape_reddit, scrape_mastodon, scrape_lobsters, scrape_lemmy, scrape_discord
|
|
from scoutd.github import analyze_github_user, get_github_user
|
|
from scoutd.signals import analyze_text
|
|
from matchd.fingerprint import generate_fingerprint, fingerprint_similarity
|
|
from matchd.overlap import find_overlap
|
|
from matchd.lost import find_matches_for_lost_builders
|
|
from introd.draft import draft_intro, summarize_human, summarize_overlap
|
|
from introd.lost_intro import draft_lost_intro, get_lost_intro_config
|
|
from introd.send import send_email
|
|
from introd.deliver import deliver_intro, determine_best_contact
|
|
from config import get_lost_config
|
|
|
|
# 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)
|
|
MAX_INTROS_PER_DAY = 20 # rate limit outreach
|
|
MIN_OVERLAP_PRIORITY = 30 # min score for priority user matches
|
|
MIN_OVERLAP_STRANGERS = 50 # higher bar for stranger intros
|
|
|
|
|
|
class ConnectDaemon:
|
|
def __init__(self, dry_run=False):
|
|
self.db = Database()
|
|
init_users_table(self.db.conn)
|
|
self.running = True
|
|
self.dry_run = dry_run
|
|
self.last_scout = None
|
|
self.last_match = None
|
|
self.last_intro = None
|
|
self.last_lost = None
|
|
self.intros_today = 0
|
|
self.lost_intros_today = 0
|
|
self.today = datetime.now().date()
|
|
|
|
# handle shutdown gracefully
|
|
signal.signal(signal.SIGINT, self._shutdown)
|
|
signal.signal(signal.SIGTERM, self._shutdown)
|
|
|
|
def _shutdown(self, signum, frame):
|
|
print("\nconnectd: shutting down...")
|
|
self.running = False
|
|
|
|
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
|
|
self.log("reset daily intro limits")
|
|
|
|
def scout_cycle(self):
|
|
"""run discovery on all platforms"""
|
|
self.log("starting scout cycle...")
|
|
|
|
try:
|
|
scrape_github(self.db, limit_per_source=30)
|
|
except Exception as e:
|
|
self.log(f"github scout error: {e}")
|
|
|
|
try:
|
|
scrape_reddit(self.db, limit_per_sub=30)
|
|
except Exception as e:
|
|
self.log(f"reddit scout error: {e}")
|
|
|
|
try:
|
|
scrape_mastodon(self.db, limit_per_instance=30)
|
|
except Exception as e:
|
|
self.log(f"mastodon scout error: {e}")
|
|
|
|
try:
|
|
scrape_lobsters(self.db)
|
|
except Exception as e:
|
|
self.log(f"lobsters scout error: {e}")
|
|
|
|
try:
|
|
scrape_lemmy(self.db, limit_per_community=30)
|
|
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.last_scout = datetime.now()
|
|
stats = self.db.stats()
|
|
self.log(f"scout complete: {stats['total_humans']} humans in db")
|
|
|
|
def match_priority_users(self):
|
|
"""find matches for priority users (hosts)"""
|
|
priority_users = get_priority_users(self.db.conn)
|
|
|
|
if not priority_users:
|
|
return
|
|
|
|
self.log(f"matching for {len(priority_users)} priority users...")
|
|
|
|
humans = self.db.get_all_humans(min_score=20, limit=500)
|
|
|
|
for puser in priority_users:
|
|
# build priority user's fingerprint from their linked profiles
|
|
puser_signals = []
|
|
puser_text = []
|
|
|
|
if puser.get('bio'):
|
|
puser_text.append(puser['bio'])
|
|
if 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', []))
|
|
|
|
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
|
|
if puser.get('reddit') and human_user == puser['reddit'].lower():
|
|
continue
|
|
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):
|
|
human_signals = json.loads(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():
|
|
overlap_score += 20
|
|
|
|
if overlap_score >= MIN_OVERLAP_PRIORITY:
|
|
overlap_data = {
|
|
'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)
|
|
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...")
|
|
|
|
humans = self.db.get_all_humans(min_score=40, limit=200)
|
|
|
|
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
|
|
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
|
|
|
|
fp_a = fingerprints.get(human_a['id'])
|
|
fp_b = fingerprints.get(human_b['id'])
|
|
|
|
overlap = find_overlap(human_a, human_b, fp_a, fp_b)
|
|
|
|
if overlap['overlap_score'] >= MIN_OVERLAP_STRANGERS:
|
|
# save match
|
|
self.db.save_match(human_a['id'], human_b['id'], overlap)
|
|
matches_found += 1
|
|
|
|
if matches_found:
|
|
self.log(f"found {matches_found} stranger matches")
|
|
|
|
self.last_match = datetime.now()
|
|
|
|
def send_stranger_intros(self):
|
|
"""send intros to connect strangers (or preview in dry-run mode)"""
|
|
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()
|
|
|
|
if self.dry_run:
|
|
self.log(f"DRY RUN: previewing {len(matches)} potential intros")
|
|
|
|
for match in matches:
|
|
if not self.dry_run and self.intros_today >= MAX_INTROS_PER_DAY:
|
|
break
|
|
|
|
match = dict(match)
|
|
|
|
# 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'],
|
|
}
|
|
|
|
match_data = {
|
|
'id': match['id'],
|
|
'human_a': human_a,
|
|
'human_b': human_b,
|
|
'overlap_score': match['overlap_score'],
|
|
'overlap_reasons': match['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):
|
|
contact = json.loads(contact)
|
|
|
|
email = contact.get('email')
|
|
if not email:
|
|
continue
|
|
|
|
# draft intro
|
|
intro = draft_intro(match_data, recipient='a' if recipient == human_a else 'b')
|
|
|
|
# parse overlap reasons for display
|
|
reasons = match['overlap_reasons']
|
|
if isinstance(reasons, str):
|
|
reasons = json.loads(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}")
|
|
print(f"SUBJECT: you might want to meet {other['username']}")
|
|
print(f"SCORE: {match['overlap_score']:.0f} ({reason_summary})")
|
|
print("-" * 60)
|
|
print("MESSAGE:")
|
|
print(intro['draft'])
|
|
print("-" * 60)
|
|
print("[DRY RUN - NOT SENT]")
|
|
print("=" * 60)
|
|
break
|
|
else:
|
|
# actually send
|
|
success, error = send_email(
|
|
email,
|
|
f"connectd: you might want to meet {other['username']}",
|
|
intro['draft']
|
|
)
|
|
|
|
if success:
|
|
self.log(f"sent intro to {recipient['username']} ({email})")
|
|
self.intros_today += 1
|
|
|
|
# mark match as intro_sent
|
|
c.execute('UPDATE matches SET status = "intro_sent" WHERE id = ?',
|
|
(match['id'],))
|
|
self.db.conn.commit()
|
|
break
|
|
else:
|
|
self.log(f"failed to send to {email}: {error}")
|
|
|
|
self.last_intro = datetime.now()
|
|
|
|
def send_lost_builder_intros(self):
|
|
"""
|
|
reach out to lost builders - different tone, lower volume.
|
|
these people need encouragement, not networking.
|
|
"""
|
|
self.reset_daily_limits()
|
|
|
|
lost_config = get_lost_config()
|
|
|
|
if not lost_config.get('enabled', True):
|
|
return
|
|
|
|
max_per_day = lost_config.get('max_per_day', 5)
|
|
if not self.dry_run and self.lost_intros_today >= max_per_day:
|
|
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),
|
|
limit=max_per_day - self.lost_intros_today
|
|
)
|
|
|
|
if error:
|
|
self.log(f"lost builder matching error: {error}")
|
|
return
|
|
|
|
if not matches:
|
|
self.log("no lost builders ready for outreach")
|
|
return
|
|
|
|
if self.dry_run:
|
|
self.log(f"DRY RUN: previewing {len(matches)} lost builder intros")
|
|
|
|
for match in matches:
|
|
if not self.dry_run and self.lost_intros_today >= max_per_day:
|
|
break
|
|
|
|
lost = match['lost_user']
|
|
builder = match['inspiring_builder']
|
|
|
|
lost_name = lost.get('name') or lost.get('username')
|
|
builder_name = builder.get('name') or builder.get('username')
|
|
|
|
# draft intro
|
|
draft, draft_error = draft_lost_intro(lost, 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:
|
|
print("\n" + "=" * 60)
|
|
print("LOST BUILDER OUTREACH")
|
|
print("=" * 60)
|
|
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)
|
|
print("-" * 60)
|
|
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', []),
|
|
}
|
|
|
|
success, error, delivery_method = deliver_intro(match_data, draft)
|
|
|
|
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}")
|
|
|
|
self.last_lost = datetime.now()
|
|
self.log(f"lost builder cycle complete: {self.lost_intros_today} sent today")
|
|
|
|
def run(self):
|
|
"""main daemon loop"""
|
|
self.log("connectd daemon starting...")
|
|
if self.dry_run:
|
|
self.log("*** DRY RUN MODE - no intros will be sent ***")
|
|
self.log(f"scout interval: {SCOUT_INTERVAL}s")
|
|
self.log(f"match interval: {MATCH_INTERVAL}s")
|
|
self.log(f"intro interval: {INTRO_INTERVAL}s")
|
|
self.log(f"lost interval: {LOST_INTERVAL}s")
|
|
self.log(f"max intros/day: {MAX_INTROS_PER_DAY}")
|
|
|
|
# initial scout
|
|
self.scout_cycle()
|
|
|
|
while self.running:
|
|
now = datetime.now()
|
|
|
|
# scout cycle
|
|
if not self.last_scout or (now - self.last_scout).seconds >= SCOUT_INTERVAL:
|
|
self.scout_cycle()
|
|
|
|
# match cycle
|
|
if not self.last_match or (now - self.last_match).seconds >= MATCH_INTERVAL:
|
|
self.match_priority_users()
|
|
self.match_strangers()
|
|
|
|
# intro cycle
|
|
if not self.last_intro or (now - self.last_intro).seconds >= INTRO_INTERVAL:
|
|
self.send_stranger_intros()
|
|
|
|
# lost builder cycle
|
|
if not self.last_lost or (now - self.last_lost).seconds >= LOST_INTERVAL:
|
|
self.send_lost_builder_intros()
|
|
|
|
# sleep between checks
|
|
time.sleep(60)
|
|
|
|
self.log("connectd daemon stopped")
|
|
self.db.close()
|
|
|
|
|
|
def run_daemon(dry_run=False):
|
|
"""entry point"""
|
|
daemon = ConnectDaemon(dry_run=dry_run)
|
|
daemon.run()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
dry_run = '--dry-run' in sys.argv
|
|
run_daemon(dry_run=dry_run)
|