mirror of
https://github.com/sudoxnym/connectd.git
synced 2026-04-14 03:27:24 +00:00
879 lines
33 KiB
Python
879 lines
33 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
connectd - people discovery and matchmaking daemon
|
||
|
|
finds isolated builders and connects them
|
||
|
|
also finds LOST builders who need encouragement
|
||
|
|
|
||
|
|
usage:
|
||
|
|
connectd scout # run all scrapers
|
||
|
|
connectd scout --github # github only
|
||
|
|
connectd scout --reddit # reddit only
|
||
|
|
connectd scout --mastodon # mastodon only
|
||
|
|
connectd scout --lobsters # lobste.rs only
|
||
|
|
connectd scout --matrix # matrix only
|
||
|
|
connectd scout --lost # show lost builder stats after scout
|
||
|
|
|
||
|
|
connectd match # find all matches
|
||
|
|
connectd match --top 20 # show top 20 matches
|
||
|
|
connectd match --mine # show YOUR matches (priority user)
|
||
|
|
connectd match --lost # find matches for lost builders
|
||
|
|
|
||
|
|
connectd intro # generate intros for top matches
|
||
|
|
connectd intro --match 123 # generate intro for specific match
|
||
|
|
connectd intro --dry-run # preview intros without saving
|
||
|
|
connectd intro --lost # generate intros for lost builders
|
||
|
|
|
||
|
|
connectd review # interactive review queue
|
||
|
|
connectd send # send all approved intros
|
||
|
|
connectd send --export # export for manual sending
|
||
|
|
|
||
|
|
connectd daemon # run as continuous daemon
|
||
|
|
connectd daemon --oneshot # run once then exit
|
||
|
|
connectd daemon --dry-run # run but never send intros
|
||
|
|
connectd daemon --oneshot --dry-run # one cycle, preview only
|
||
|
|
|
||
|
|
connectd user # show your priority user profile
|
||
|
|
connectd user --setup # setup/update your profile
|
||
|
|
connectd user --matches # show matches found for you
|
||
|
|
|
||
|
|
connectd status # show database stats (including lost builders)
|
||
|
|
connectd lost # show lost builders ready for outreach
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
# add parent to path for imports
|
||
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
||
|
|
|
||
|
|
from db import Database
|
||
|
|
from db.users import (init_users_table, add_priority_user, get_priority_users,
|
||
|
|
get_priority_user_matches, score_priority_user, auto_match_priority_user,
|
||
|
|
update_priority_user_profile)
|
||
|
|
from scoutd import scrape_github, scrape_reddit, scrape_mastodon, scrape_lobsters, scrape_matrix
|
||
|
|
from scoutd.deep import deep_scrape_github_user
|
||
|
|
from scoutd.lost import get_signal_descriptions
|
||
|
|
from introd.deliver import (deliver_intro, deliver_batch, get_delivery_stats,
|
||
|
|
review_manual_queue, determine_best_contact, load_manual_queue,
|
||
|
|
save_manual_queue)
|
||
|
|
from matchd import find_all_matches, generate_fingerprint
|
||
|
|
from matchd.rank import get_top_matches
|
||
|
|
from matchd.lost import find_matches_for_lost_builders, get_lost_match_summary
|
||
|
|
from introd import draft_intro
|
||
|
|
from introd.draft import draft_intros_for_match
|
||
|
|
from introd.lost_intro import draft_lost_intro, get_lost_intro_config
|
||
|
|
from introd.review import review_all_pending, get_pending_intros
|
||
|
|
from introd.send import send_all_approved, export_manual_intros
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_scout(args, db):
|
||
|
|
"""run discovery scrapers"""
|
||
|
|
from scoutd.deep import deep_scrape_github_user, save_deep_profile
|
||
|
|
|
||
|
|
print("=" * 60)
|
||
|
|
print("connectd scout - discovering aligned humans")
|
||
|
|
print("=" * 60)
|
||
|
|
|
||
|
|
# deep scrape specific user
|
||
|
|
if args.user:
|
||
|
|
print(f"\ndeep scraping github user: {args.user}")
|
||
|
|
profile = deep_scrape_github_user(args.user)
|
||
|
|
if profile:
|
||
|
|
save_deep_profile(db, profile)
|
||
|
|
print(f"\n=== {profile['username']} ===")
|
||
|
|
print(f"real name: {profile.get('real_name')}")
|
||
|
|
print(f"location: {profile.get('location')}")
|
||
|
|
print(f"company: {profile.get('company')}")
|
||
|
|
print(f"email: {profile.get('email')}")
|
||
|
|
print(f"twitter: {profile.get('twitter')}")
|
||
|
|
print(f"mastodon: {profile.get('mastodon')}")
|
||
|
|
print(f"orgs: {', '.join(profile.get('orgs', []))}")
|
||
|
|
print(f"languages: {', '.join(list(profile.get('languages', {}).keys())[:5])}")
|
||
|
|
print(f"topics: {', '.join(profile.get('topics', [])[:10])}")
|
||
|
|
print(f"signals: {', '.join(profile.get('signals', []))}")
|
||
|
|
print(f"score: {profile.get('score')}")
|
||
|
|
if profile.get('linked_profiles'):
|
||
|
|
print(f"linked profiles: {list(profile['linked_profiles'].keys())}")
|
||
|
|
else:
|
||
|
|
print("failed to scrape user")
|
||
|
|
return
|
||
|
|
|
||
|
|
run_all = not any([args.github, args.reddit, args.mastodon, args.lobsters, args.matrix, args.twitter, args.bluesky, args.lemmy, args.discord])
|
||
|
|
|
||
|
|
if args.github or run_all:
|
||
|
|
if args.deep:
|
||
|
|
# deep scrape mode - slower but more thorough
|
||
|
|
print("\nrunning DEEP github scrape (follows all links)...")
|
||
|
|
from scoutd.github import get_repo_contributors
|
||
|
|
from scoutd.signals import ECOSYSTEM_REPOS
|
||
|
|
|
||
|
|
all_logins = set()
|
||
|
|
for repo in ECOSYSTEM_REPOS[:5]: # limit for deep mode
|
||
|
|
contributors = get_repo_contributors(repo, per_page=20)
|
||
|
|
for c in contributors:
|
||
|
|
login = c.get('login')
|
||
|
|
if login and not login.endswith('[bot]'):
|
||
|
|
all_logins.add(login)
|
||
|
|
print(f" {repo}: {len(contributors)} contributors")
|
||
|
|
|
||
|
|
print(f"\ndeep scraping {len(all_logins)} users...")
|
||
|
|
for login in all_logins:
|
||
|
|
try:
|
||
|
|
profile = deep_scrape_github_user(login)
|
||
|
|
if profile and profile.get('score', 0) > 0:
|
||
|
|
save_deep_profile(db, profile)
|
||
|
|
if profile['score'] >= 30:
|
||
|
|
print(f" ★ {login}: {profile['score']} pts")
|
||
|
|
if profile.get('email'):
|
||
|
|
print(f" email: {profile['email']}")
|
||
|
|
if profile.get('mastodon'):
|
||
|
|
print(f" mastodon: {profile['mastodon']}")
|
||
|
|
except Exception as e:
|
||
|
|
print(f" error on {login}: {e}")
|
||
|
|
else:
|
||
|
|
scrape_github(db)
|
||
|
|
|
||
|
|
if args.reddit or run_all:
|
||
|
|
scrape_reddit(db)
|
||
|
|
|
||
|
|
if args.mastodon or run_all:
|
||
|
|
scrape_mastodon(db)
|
||
|
|
|
||
|
|
if args.lobsters or run_all:
|
||
|
|
scrape_lobsters(db)
|
||
|
|
|
||
|
|
if args.matrix or run_all:
|
||
|
|
scrape_matrix(db)
|
||
|
|
|
||
|
|
if args.twitter or run_all:
|
||
|
|
from scoutd.twitter import scrape_twitter
|
||
|
|
scrape_twitter(db)
|
||
|
|
|
||
|
|
if args.bluesky or run_all:
|
||
|
|
from scoutd.bluesky import scrape_bluesky
|
||
|
|
scrape_bluesky(db)
|
||
|
|
|
||
|
|
if args.lemmy or run_all:
|
||
|
|
from scoutd.lemmy import scrape_lemmy
|
||
|
|
scrape_lemmy(db)
|
||
|
|
|
||
|
|
if args.discord or run_all:
|
||
|
|
from scoutd.discord import scrape_discord
|
||
|
|
scrape_discord(db)
|
||
|
|
|
||
|
|
# show stats
|
||
|
|
stats = db.stats()
|
||
|
|
print("\n" + "=" * 60)
|
||
|
|
print("SCOUT COMPLETE")
|
||
|
|
print("=" * 60)
|
||
|
|
print(f"total humans: {stats['total_humans']}")
|
||
|
|
for platform, count in stats.get('by_platform', {}).items():
|
||
|
|
print(f" {platform}: {count}")
|
||
|
|
|
||
|
|
# show lost builder stats if requested
|
||
|
|
if args.lost or True: # always show lost stats now
|
||
|
|
print("\n--- lost builder stats ---")
|
||
|
|
print(f"active builders: {stats.get('active_builders', 0)}")
|
||
|
|
print(f"lost builders: {stats.get('lost_builders', 0)}")
|
||
|
|
print(f"recovering builders: {stats.get('recovering_builders', 0)}")
|
||
|
|
print(f"high lost score (40+): {stats.get('high_lost_score', 0)}")
|
||
|
|
print(f"lost outreach sent: {stats.get('lost_outreach_sent', 0)}")
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_match(args, db):
|
||
|
|
"""find and rank matches"""
|
||
|
|
import json as json_mod
|
||
|
|
|
||
|
|
print("=" * 60)
|
||
|
|
print("connectd match - finding aligned pairs")
|
||
|
|
print("=" * 60)
|
||
|
|
|
||
|
|
# lost builder matching
|
||
|
|
if args.lost:
|
||
|
|
print("\n--- LOST BUILDER MATCHING ---")
|
||
|
|
print("finding inspiring builders for lost souls...\n")
|
||
|
|
|
||
|
|
matches, error = find_matches_for_lost_builders(db, limit=args.top or 20)
|
||
|
|
|
||
|
|
if error:
|
||
|
|
print(f"error: {error}")
|
||
|
|
return
|
||
|
|
|
||
|
|
if not matches:
|
||
|
|
print("no lost builders ready for outreach")
|
||
|
|
return
|
||
|
|
|
||
|
|
print(f"found {len(matches)} lost builders with matching active builders\n")
|
||
|
|
|
||
|
|
for i, match in enumerate(matches, 1):
|
||
|
|
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')
|
||
|
|
|
||
|
|
print(f"{i}. {lost_name} ({lost.get('platform')}) → needs inspiration from")
|
||
|
|
print(f" {builder_name} ({builder.get('platform')})")
|
||
|
|
print(f" lost score: {lost.get('lost_potential_score', 0)} | values: {lost.get('score', 0)}")
|
||
|
|
print(f" shared interests: {', '.join(match.get('shared_interests', []))}")
|
||
|
|
print(f" builder has: {match.get('builder_repos', 0)} repos, {match.get('builder_stars', 0)} stars")
|
||
|
|
print()
|
||
|
|
|
||
|
|
return
|
||
|
|
|
||
|
|
if args.mine:
|
||
|
|
# show matches for priority user
|
||
|
|
init_users_table(db.conn)
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
if not users:
|
||
|
|
print("no priority user configured. run: connectd user --setup")
|
||
|
|
return
|
||
|
|
|
||
|
|
for user in users:
|
||
|
|
print(f"\n=== matches for {user['name']} ===\n")
|
||
|
|
matches = get_priority_user_matches(db.conn, user['id'], limit=args.top or 20)
|
||
|
|
|
||
|
|
if not matches:
|
||
|
|
print("no matches yet - run: connectd scout && connectd match")
|
||
|
|
continue
|
||
|
|
|
||
|
|
for i, match in enumerate(matches, 1):
|
||
|
|
print(f"{i}. {match['username']} ({match['platform']})")
|
||
|
|
print(f" score: {match['overlap_score']:.0f}")
|
||
|
|
print(f" url: {match['url']}")
|
||
|
|
reasons = match.get('overlap_reasons', '[]')
|
||
|
|
if isinstance(reasons, str):
|
||
|
|
reasons = json_mod.loads(reasons)
|
||
|
|
if reasons:
|
||
|
|
print(f" why: {reasons[0]}")
|
||
|
|
print()
|
||
|
|
return
|
||
|
|
|
||
|
|
if args.top and not args.mine:
|
||
|
|
# just show existing top matches
|
||
|
|
matches = get_top_matches(db, limit=args.top)
|
||
|
|
else:
|
||
|
|
# run full matching
|
||
|
|
matches = find_all_matches(db, min_score=args.min_score, min_overlap=args.min_overlap)
|
||
|
|
|
||
|
|
print("\n" + "-" * 60)
|
||
|
|
print(f"TOP {min(len(matches), args.top or 20)} MATCHES")
|
||
|
|
print("-" * 60)
|
||
|
|
|
||
|
|
for i, match in enumerate(matches[:args.top or 20], 1):
|
||
|
|
human_a = match.get('human_a', {})
|
||
|
|
human_b = match.get('human_b', {})
|
||
|
|
|
||
|
|
print(f"\n{i}. {human_a.get('username')} <-> {human_b.get('username')}")
|
||
|
|
print(f" platforms: {human_a.get('platform')} / {human_b.get('platform')}")
|
||
|
|
print(f" overlap: {match.get('overlap_score', 0):.0f} pts")
|
||
|
|
|
||
|
|
reasons = match.get('overlap_reasons', [])
|
||
|
|
if isinstance(reasons, str):
|
||
|
|
reasons = json_mod.loads(reasons)
|
||
|
|
if reasons:
|
||
|
|
print(f" why: {' | '.join(reasons[:3])}")
|
||
|
|
|
||
|
|
if match.get('geographic_match'):
|
||
|
|
print(f" location: compatible ✓")
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_intro(args, db):
|
||
|
|
"""generate intro drafts"""
|
||
|
|
import json as json_mod
|
||
|
|
|
||
|
|
print("=" * 60)
|
||
|
|
print("connectd intro - drafting introductions")
|
||
|
|
print("=" * 60)
|
||
|
|
|
||
|
|
if args.dry_run:
|
||
|
|
print("*** DRY RUN MODE - previewing only ***\n")
|
||
|
|
|
||
|
|
# lost builder intros - different tone entirely
|
||
|
|
if args.lost:
|
||
|
|
print("\n--- LOST BUILDER INTROS ---")
|
||
|
|
print("drafting encouragement for lost souls...\n")
|
||
|
|
|
||
|
|
matches, error = find_matches_for_lost_builders(db, limit=args.limit or 10)
|
||
|
|
|
||
|
|
if error:
|
||
|
|
print(f"error: {error}")
|
||
|
|
return
|
||
|
|
|
||
|
|
if not matches:
|
||
|
|
print("no lost builders ready for outreach")
|
||
|
|
return
|
||
|
|
|
||
|
|
config = get_lost_intro_config()
|
||
|
|
count = 0
|
||
|
|
|
||
|
|
for match in matches:
|
||
|
|
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, error = draft_lost_intro(lost, builder, config)
|
||
|
|
|
||
|
|
if error:
|
||
|
|
print(f" error drafting intro for {lost_name}: {error}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
if args.dry_run:
|
||
|
|
print("=" * 60)
|
||
|
|
print(f"TO: {lost_name} ({lost.get('platform')})")
|
||
|
|
print(f"LOST SCORE: {lost.get('lost_potential_score', 0)}")
|
||
|
|
print(f"INSPIRING: {builder_name} ({builder.get('url')})")
|
||
|
|
print("-" * 60)
|
||
|
|
print("MESSAGE:")
|
||
|
|
print(draft)
|
||
|
|
print("-" * 60)
|
||
|
|
print("[DRY RUN - NOT SAVED]")
|
||
|
|
print("=" * 60)
|
||
|
|
else:
|
||
|
|
print(f" drafted intro for {lost_name} → {builder_name}")
|
||
|
|
|
||
|
|
count += 1
|
||
|
|
|
||
|
|
if args.dry_run:
|
||
|
|
print(f"\npreviewed {count} lost builder intros (dry run)")
|
||
|
|
else:
|
||
|
|
print(f"\ndrafted {count} lost builder intros")
|
||
|
|
print("these require manual review before sending")
|
||
|
|
|
||
|
|
return
|
||
|
|
|
||
|
|
if args.match:
|
||
|
|
# specific match
|
||
|
|
matches = [m for m in get_top_matches(db, limit=1000) if m.get('id') == args.match]
|
||
|
|
else:
|
||
|
|
# top matches
|
||
|
|
matches = get_top_matches(db, limit=args.limit or 10)
|
||
|
|
|
||
|
|
if not matches:
|
||
|
|
print("no matches found")
|
||
|
|
return
|
||
|
|
|
||
|
|
print(f"generating intros for {len(matches)} matches...")
|
||
|
|
|
||
|
|
count = 0
|
||
|
|
for match in matches:
|
||
|
|
intros = draft_intros_for_match(match)
|
||
|
|
|
||
|
|
for intro in intros:
|
||
|
|
recipient = intro['recipient_human']
|
||
|
|
other = intro['other_human']
|
||
|
|
|
||
|
|
if args.dry_run:
|
||
|
|
# get contact info
|
||
|
|
contact = recipient.get('contact', {})
|
||
|
|
if isinstance(contact, str):
|
||
|
|
contact = json_mod.loads(contact)
|
||
|
|
email = contact.get('email', 'no email')
|
||
|
|
|
||
|
|
# get overlap reasons
|
||
|
|
reasons = match.get('overlap_reasons', [])
|
||
|
|
if isinstance(reasons, str):
|
||
|
|
reasons = json_mod.loads(reasons)
|
||
|
|
reason_summary = ', '.join(reasons[:3]) if reasons else 'aligned values'
|
||
|
|
|
||
|
|
# print preview
|
||
|
|
print("\n" + "=" * 60)
|
||
|
|
print(f"TO: {recipient.get('username')} ({recipient.get('platform')})")
|
||
|
|
print(f"EMAIL: {email}")
|
||
|
|
print(f"SUBJECT: you might want to meet {other.get('username')}")
|
||
|
|
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:
|
||
|
|
print(f"\n {recipient.get('username')} ({intro['channel']})")
|
||
|
|
|
||
|
|
# save to db
|
||
|
|
db.save_intro(
|
||
|
|
match.get('id'),
|
||
|
|
recipient.get('id'),
|
||
|
|
intro['channel'],
|
||
|
|
intro['draft']
|
||
|
|
)
|
||
|
|
|
||
|
|
count += 1
|
||
|
|
|
||
|
|
if args.dry_run:
|
||
|
|
print(f"\npreviewed {count} intros (dry run - nothing saved)")
|
||
|
|
else:
|
||
|
|
print(f"\ngenerated {count} intro drafts")
|
||
|
|
print("run 'connectd review' to approve before sending")
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_review(args, db):
|
||
|
|
"""interactive review queue"""
|
||
|
|
review_all_pending(db)
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_send(args, db):
|
||
|
|
"""send approved intros"""
|
||
|
|
import json as json_mod
|
||
|
|
|
||
|
|
if args.export:
|
||
|
|
# export manual queue to file for review
|
||
|
|
queue = load_manual_queue()
|
||
|
|
pending = [q for q in queue if q.get('status') == 'pending']
|
||
|
|
|
||
|
|
with open(args.export, 'w') as f:
|
||
|
|
json.dump(pending, f, indent=2)
|
||
|
|
|
||
|
|
print(f"exported {len(pending)} pending intros to {args.export}")
|
||
|
|
return
|
||
|
|
|
||
|
|
# send all approved from manual queue
|
||
|
|
queue = load_manual_queue()
|
||
|
|
approved = [q for q in queue if q.get('status') == 'approved']
|
||
|
|
|
||
|
|
if not approved:
|
||
|
|
print("no approved intros to send")
|
||
|
|
print("use 'connectd review' to approve intros first")
|
||
|
|
return
|
||
|
|
|
||
|
|
print(f"sending {len(approved)} approved intros...")
|
||
|
|
|
||
|
|
for item in approved:
|
||
|
|
match_data = item.get('match', {})
|
||
|
|
intro_draft = item.get('draft', '')
|
||
|
|
recipient = item.get('recipient', {})
|
||
|
|
|
||
|
|
success, error, method = deliver_intro(
|
||
|
|
{'human_b': recipient, **match_data},
|
||
|
|
intro_draft,
|
||
|
|
dry_run=args.dry_run if hasattr(args, 'dry_run') else False
|
||
|
|
)
|
||
|
|
|
||
|
|
status = 'ok' if success else f'failed: {error}'
|
||
|
|
print(f" {recipient.get('username')}: {method} - {status}")
|
||
|
|
|
||
|
|
# update queue status
|
||
|
|
item['status'] = 'sent' if success else 'failed'
|
||
|
|
item['error'] = error
|
||
|
|
|
||
|
|
save_manual_queue(queue)
|
||
|
|
|
||
|
|
# show stats
|
||
|
|
stats = get_delivery_stats()
|
||
|
|
print(f"\ndelivery stats: {stats['sent']} sent, {stats['failed']} failed")
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_lost(args, db):
|
||
|
|
"""show lost builders ready for outreach"""
|
||
|
|
import json as json_mod
|
||
|
|
|
||
|
|
print("=" * 60)
|
||
|
|
print("connectd lost - lost builders who need encouragement")
|
||
|
|
print("=" * 60)
|
||
|
|
|
||
|
|
# get lost builders
|
||
|
|
lost_builders = db.get_lost_builders_for_outreach(
|
||
|
|
min_lost_score=args.min_score or 40,
|
||
|
|
min_values_score=20,
|
||
|
|
limit=args.limit or 50
|
||
|
|
)
|
||
|
|
|
||
|
|
if not lost_builders:
|
||
|
|
print("\nno lost builders ready for outreach")
|
||
|
|
print("run 'connectd scout' to discover more")
|
||
|
|
return
|
||
|
|
|
||
|
|
print(f"\n{len(lost_builders)} lost builders ready for outreach:\n")
|
||
|
|
|
||
|
|
for i, lost in enumerate(lost_builders, 1):
|
||
|
|
name = lost.get('name') or lost.get('username')
|
||
|
|
platform = lost.get('platform')
|
||
|
|
lost_score = lost.get('lost_potential_score', 0)
|
||
|
|
values_score = lost.get('score', 0)
|
||
|
|
|
||
|
|
# parse lost signals
|
||
|
|
lost_signals = lost.get('lost_signals', [])
|
||
|
|
if isinstance(lost_signals, str):
|
||
|
|
lost_signals = json_mod.loads(lost_signals) if lost_signals else []
|
||
|
|
|
||
|
|
# get signal descriptions
|
||
|
|
signal_descriptions = get_signal_descriptions(lost_signals)
|
||
|
|
|
||
|
|
print(f"{i}. {name} ({platform})")
|
||
|
|
print(f" lost score: {lost_score} | values score: {values_score}")
|
||
|
|
print(f" url: {lost.get('url')}")
|
||
|
|
if signal_descriptions:
|
||
|
|
print(f" why lost: {', '.join(signal_descriptions[:3])}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
if args.verbose:
|
||
|
|
print("-" * 60)
|
||
|
|
print("these people need encouragement, not networking.")
|
||
|
|
print("the goal: show them someone like them made it.")
|
||
|
|
print("-" * 60)
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_status(args, db):
|
||
|
|
"""show database stats"""
|
||
|
|
import json as json_mod
|
||
|
|
|
||
|
|
init_users_table(db.conn)
|
||
|
|
stats = db.stats()
|
||
|
|
|
||
|
|
print("=" * 60)
|
||
|
|
print("connectd status")
|
||
|
|
print("=" * 60)
|
||
|
|
|
||
|
|
# priority users
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
print(f"\npriority users: {len(users)}")
|
||
|
|
for user in users:
|
||
|
|
print(f" - {user['name']} ({user['email']})")
|
||
|
|
|
||
|
|
print(f"\nhumans discovered: {stats['total_humans']}")
|
||
|
|
print(f" high-score (50+): {stats['high_score_humans']}")
|
||
|
|
|
||
|
|
print("\nby platform:")
|
||
|
|
for platform, count in stats.get('by_platform', {}).items():
|
||
|
|
print(f" {platform}: {count}")
|
||
|
|
|
||
|
|
print(f"\nstranger matches: {stats['total_matches']}")
|
||
|
|
print(f"intros created: {stats['total_intros']}")
|
||
|
|
print(f"intros sent: {stats['sent_intros']}")
|
||
|
|
|
||
|
|
# lost builder stats
|
||
|
|
print("\n--- lost builder stats ---")
|
||
|
|
print(f"active builders: {stats.get('active_builders', 0)}")
|
||
|
|
print(f"lost builders: {stats.get('lost_builders', 0)}")
|
||
|
|
print(f"recovering builders: {stats.get('recovering_builders', 0)}")
|
||
|
|
print(f"high lost score (40+): {stats.get('high_lost_score', 0)}")
|
||
|
|
print(f"lost outreach sent: {stats.get('lost_outreach_sent', 0)}")
|
||
|
|
|
||
|
|
# priority user matches
|
||
|
|
for user in users:
|
||
|
|
matches = get_priority_user_matches(db.conn, user['id'])
|
||
|
|
print(f"\nmatches for {user['name']}: {len(matches)}")
|
||
|
|
|
||
|
|
# pending intros
|
||
|
|
pending = get_pending_intros(db)
|
||
|
|
print(f"\nintros pending review: {len(pending)}")
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_daemon(args, db):
|
||
|
|
"""run as continuous daemon"""
|
||
|
|
from daemon import ConnectDaemon
|
||
|
|
|
||
|
|
daemon = ConnectDaemon(dry_run=args.dry_run)
|
||
|
|
|
||
|
|
if args.oneshot:
|
||
|
|
print("running one cycle...")
|
||
|
|
if args.dry_run:
|
||
|
|
print("*** DRY RUN MODE - no intros will be sent ***")
|
||
|
|
daemon.scout_cycle()
|
||
|
|
daemon.match_priority_users()
|
||
|
|
daemon.match_strangers()
|
||
|
|
daemon.send_stranger_intros()
|
||
|
|
print("done")
|
||
|
|
else:
|
||
|
|
daemon.run()
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_user(args, db):
|
||
|
|
"""manage priority user profile"""
|
||
|
|
import json as json_mod
|
||
|
|
|
||
|
|
init_users_table(db.conn)
|
||
|
|
|
||
|
|
if args.setup:
|
||
|
|
# interactive setup
|
||
|
|
print("=" * 60)
|
||
|
|
print("connectd priority user setup")
|
||
|
|
print("=" * 60)
|
||
|
|
print("\nlink your profiles so connectd finds matches for YOU\n")
|
||
|
|
|
||
|
|
name = input("name: ").strip()
|
||
|
|
email = input("email: ").strip()
|
||
|
|
github = input("github username: ").strip() or None
|
||
|
|
reddit = input("reddit username: ").strip() or None
|
||
|
|
mastodon = input("mastodon (user@instance): ").strip() or None
|
||
|
|
location = input("location (e.g. seattle): ").strip() or None
|
||
|
|
|
||
|
|
print("\ninterests (comma separated):")
|
||
|
|
interests_raw = input("> ").strip()
|
||
|
|
interests = [i.strip() for i in interests_raw.split(',')] if interests_raw else []
|
||
|
|
|
||
|
|
looking_for = input("looking for: ").strip() or None
|
||
|
|
|
||
|
|
user_data = {
|
||
|
|
'name': name, 'email': email, 'github': github,
|
||
|
|
'reddit': reddit, 'mastodon': mastodon,
|
||
|
|
'location': location, 'interests': interests,
|
||
|
|
'looking_for': looking_for,
|
||
|
|
}
|
||
|
|
user_id = add_priority_user(db.conn, user_data)
|
||
|
|
print(f"\n✓ added as priority user #{user_id}")
|
||
|
|
|
||
|
|
elif args.matches:
|
||
|
|
# show matches
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
if not users:
|
||
|
|
print("no priority user. run: connectd user --setup")
|
||
|
|
return
|
||
|
|
|
||
|
|
for user in users:
|
||
|
|
print(f"\n=== matches for {user['name']} ===\n")
|
||
|
|
matches = get_priority_user_matches(db.conn, user['id'], limit=20)
|
||
|
|
|
||
|
|
if not matches:
|
||
|
|
print("no matches yet")
|
||
|
|
continue
|
||
|
|
|
||
|
|
for i, match in enumerate(matches, 1):
|
||
|
|
print(f"{i}. {match['username']} ({match['platform']})")
|
||
|
|
print(f" {match['url']}")
|
||
|
|
print(f" score: {match['overlap_score']:.0f}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
else:
|
||
|
|
# show profile
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
if not users:
|
||
|
|
print("no priority user configured")
|
||
|
|
print("run: connectd user --setup")
|
||
|
|
return
|
||
|
|
|
||
|
|
for user in users:
|
||
|
|
print("=" * 60)
|
||
|
|
print(f"priority user #{user['id']}: {user['name']}")
|
||
|
|
print("=" * 60)
|
||
|
|
print(f"email: {user['email']}")
|
||
|
|
if user['github']:
|
||
|
|
print(f"github: {user['github']}")
|
||
|
|
if user['reddit']:
|
||
|
|
print(f"reddit: {user['reddit']}")
|
||
|
|
if user['mastodon']:
|
||
|
|
print(f"mastodon: {user['mastodon']}")
|
||
|
|
if user['location']:
|
||
|
|
print(f"location: {user['location']}")
|
||
|
|
if user['interests']:
|
||
|
|
interests = json_mod.loads(user['interests']) if isinstance(user['interests'], str) else user['interests']
|
||
|
|
print(f"interests: {', '.join(interests)}")
|
||
|
|
if user['looking_for']:
|
||
|
|
print(f"looking for: {user['looking_for']}")
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_me(args, db):
|
||
|
|
"""auto-score and auto-match for priority user with optional groq intros"""
|
||
|
|
import json as json_mod
|
||
|
|
|
||
|
|
init_users_table(db.conn)
|
||
|
|
|
||
|
|
# get priority user
|
||
|
|
users = get_priority_users(db.conn)
|
||
|
|
if not users:
|
||
|
|
print("no priority user configured")
|
||
|
|
print("run: connectd user --setup")
|
||
|
|
return
|
||
|
|
|
||
|
|
user = users[0] # first/main user
|
||
|
|
print("=" * 60)
|
||
|
|
print(f"connectd me - {user['name']}")
|
||
|
|
print("=" * 60)
|
||
|
|
|
||
|
|
# step 1: scrape github profile
|
||
|
|
if user.get('github') and not args.skip_scrape:
|
||
|
|
print(f"\n[1/4] scraping github profile: {user['github']}")
|
||
|
|
profile = deep_scrape_github_user(user['github'], scrape_commits=False)
|
||
|
|
if profile:
|
||
|
|
print(f" repos: {len(profile.get('top_repos', []))}")
|
||
|
|
print(f" languages: {', '.join(list(profile.get('languages', {}).keys())[:5])}")
|
||
|
|
else:
|
||
|
|
print(" failed to scrape (rate limited?)")
|
||
|
|
profile = None
|
||
|
|
else:
|
||
|
|
print("\n[1/4] skipping github scrape (using saved profile)")
|
||
|
|
# use saved profile if available
|
||
|
|
saved = user.get('scraped_profile')
|
||
|
|
if saved:
|
||
|
|
profile = json_mod.loads(saved) if isinstance(saved, str) else saved
|
||
|
|
print(f" loaded saved profile: {len(profile.get('top_repos', []))} repos")
|
||
|
|
else:
|
||
|
|
profile = None
|
||
|
|
|
||
|
|
# step 2: calculate score
|
||
|
|
print(f"\n[2/4] calculating your score...")
|
||
|
|
result = score_priority_user(db.conn, user['id'], profile)
|
||
|
|
if result:
|
||
|
|
print(f" score: {result['score']}")
|
||
|
|
print(f" signals: {', '.join(sorted(result['signals'])[:10])}")
|
||
|
|
|
||
|
|
# step 3: find matches
|
||
|
|
print(f"\n[3/4] finding matches...")
|
||
|
|
matches = auto_match_priority_user(db.conn, user['id'], min_overlap=args.min_overlap)
|
||
|
|
print(f" found {len(matches)} matches")
|
||
|
|
|
||
|
|
# step 4: show results (optionally with groq intros)
|
||
|
|
print(f"\n[4/4] top matches:")
|
||
|
|
print("-" * 60)
|
||
|
|
|
||
|
|
limit = args.limit or 10
|
||
|
|
for i, m in enumerate(matches[:limit], 1):
|
||
|
|
human = m['human']
|
||
|
|
shared = m['shared']
|
||
|
|
|
||
|
|
print(f"\n{i}. {human.get('name') or human['username']} ({human['platform']})")
|
||
|
|
print(f" {human.get('url', '')}")
|
||
|
|
print(f" score: {human.get('score', 0):.0f} | overlap: {m['overlap_score']:.0f}")
|
||
|
|
print(f" location: {human.get('location') or 'unknown'}")
|
||
|
|
print(f" why: {', '.join(shared[:5])}")
|
||
|
|
|
||
|
|
# groq intro draft
|
||
|
|
if args.groq:
|
||
|
|
try:
|
||
|
|
from introd.groq_draft import draft_intro_with_llm
|
||
|
|
match_data = {
|
||
|
|
'human_a': {'name': user['name'], 'username': user.get('github'),
|
||
|
|
'platform': 'github', 'signals': result.get('signals', []) if result else [],
|
||
|
|
'bio': user.get('bio'), 'location': user.get('location'),
|
||
|
|
'extra': profile or {}},
|
||
|
|
'human_b': human,
|
||
|
|
'overlap_score': m['overlap_score'],
|
||
|
|
'overlap_reasons': shared,
|
||
|
|
}
|
||
|
|
intro, err = draft_intro_with_llm(match_data, recipient='b')
|
||
|
|
if intro:
|
||
|
|
print(f"\n --- groq draft ({intro.get('contact_method', 'manual')}) ---")
|
||
|
|
if intro.get('contact_info'):
|
||
|
|
print(f" deliver via: {intro['contact_info']}")
|
||
|
|
for line in intro['draft'].split('\n'):
|
||
|
|
print(f" {line}")
|
||
|
|
print(f" ------------------")
|
||
|
|
elif err:
|
||
|
|
print(f" [groq error: {err}]")
|
||
|
|
except Exception as e:
|
||
|
|
print(f" [groq error: {e}]")
|
||
|
|
|
||
|
|
# summary
|
||
|
|
print("\n" + "=" * 60)
|
||
|
|
print(f"your score: {result['score'] if result else 'unknown'}")
|
||
|
|
print(f"matches found: {len(matches)}")
|
||
|
|
if args.groq:
|
||
|
|
print("groq intros: enabled")
|
||
|
|
else:
|
||
|
|
print("tip: add --groq to generate ai intro drafts")
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description='connectd - people discovery and matchmaking daemon',
|
||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
|
|
epilog=__doc__
|
||
|
|
)
|
||
|
|
|
||
|
|
subparsers = parser.add_subparsers(dest='command', help='commands')
|
||
|
|
|
||
|
|
# scout command
|
||
|
|
scout_parser = subparsers.add_parser('scout', help='discover aligned humans')
|
||
|
|
scout_parser.add_argument('--github', action='store_true', help='github only')
|
||
|
|
scout_parser.add_argument('--reddit', action='store_true', help='reddit only')
|
||
|
|
scout_parser.add_argument('--mastodon', action='store_true', help='mastodon only')
|
||
|
|
scout_parser.add_argument('--lobsters', action='store_true', help='lobste.rs only')
|
||
|
|
scout_parser.add_argument('--matrix', action='store_true', help='matrix only')
|
||
|
|
scout_parser.add_argument('--twitter', action='store_true', help='twitter/x via nitter')
|
||
|
|
scout_parser.add_argument('--bluesky', action='store_true', help='bluesky/atproto')
|
||
|
|
scout_parser.add_argument('--lemmy', action='store_true', help='lemmy (fediverse reddit)')
|
||
|
|
scout_parser.add_argument('--discord', action='store_true', help='discord servers')
|
||
|
|
scout_parser.add_argument('--deep', action='store_true', help='deep scrape - follow all links')
|
||
|
|
scout_parser.add_argument('--user', type=str, help='deep scrape specific github user')
|
||
|
|
scout_parser.add_argument('--lost', action='store_true', help='show lost builder stats')
|
||
|
|
|
||
|
|
# match command
|
||
|
|
match_parser = subparsers.add_parser('match', help='find and rank matches')
|
||
|
|
match_parser.add_argument('--top', type=int, help='show top N matches')
|
||
|
|
match_parser.add_argument('--mine', action='store_true', help='show YOUR matches')
|
||
|
|
match_parser.add_argument('--lost', action='store_true', help='find matches for lost builders')
|
||
|
|
match_parser.add_argument('--min-score', type=int, default=30, help='min human score')
|
||
|
|
match_parser.add_argument('--min-overlap', type=int, default=20, help='min overlap score')
|
||
|
|
|
||
|
|
# intro command
|
||
|
|
intro_parser = subparsers.add_parser('intro', help='generate intro drafts')
|
||
|
|
intro_parser.add_argument('--match', type=int, help='specific match id')
|
||
|
|
intro_parser.add_argument('--limit', type=int, default=10, help='number of matches')
|
||
|
|
intro_parser.add_argument('--dry-run', action='store_true', help='preview only, do not save')
|
||
|
|
intro_parser.add_argument('--lost', action='store_true', help='generate intros for lost builders')
|
||
|
|
|
||
|
|
# lost command - show lost builders ready for outreach
|
||
|
|
lost_parser = subparsers.add_parser('lost', help='show lost builders who need encouragement')
|
||
|
|
lost_parser.add_argument('--min-score', type=int, default=40, help='min lost score')
|
||
|
|
lost_parser.add_argument('--limit', type=int, default=50, help='max results')
|
||
|
|
lost_parser.add_argument('--verbose', '-v', action='store_true', help='show philosophy')
|
||
|
|
|
||
|
|
# review command
|
||
|
|
review_parser = subparsers.add_parser('review', help='review intro queue')
|
||
|
|
|
||
|
|
# send command
|
||
|
|
send_parser = subparsers.add_parser('send', help='send approved intros')
|
||
|
|
send_parser.add_argument('--export', type=str, help='export to file for manual sending')
|
||
|
|
|
||
|
|
# status command
|
||
|
|
status_parser = subparsers.add_parser('status', help='show stats')
|
||
|
|
|
||
|
|
# daemon command
|
||
|
|
daemon_parser = subparsers.add_parser('daemon', help='run as continuous daemon')
|
||
|
|
daemon_parser.add_argument('--oneshot', action='store_true', help='run once then exit')
|
||
|
|
daemon_parser.add_argument('--dry-run', action='store_true', help='preview intros, do not send')
|
||
|
|
|
||
|
|
# user command
|
||
|
|
user_parser = subparsers.add_parser('user', help='manage priority user profile')
|
||
|
|
user_parser.add_argument('--setup', action='store_true', help='setup/update profile')
|
||
|
|
user_parser.add_argument('--matches', action='store_true', help='show your matches')
|
||
|
|
|
||
|
|
# me command - auto score + match + optional groq intros
|
||
|
|
me_parser = subparsers.add_parser('me', help='auto-score and match yourself')
|
||
|
|
me_parser.add_argument('--groq', action='store_true', help='generate groq llama intro drafts')
|
||
|
|
me_parser.add_argument('--skip-scrape', action='store_true', help='skip github scraping')
|
||
|
|
me_parser.add_argument('--min-overlap', type=int, default=40, help='min overlap score')
|
||
|
|
me_parser.add_argument('--limit', type=int, default=10, help='number of matches to show')
|
||
|
|
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
if not args.command:
|
||
|
|
parser.print_help()
|
||
|
|
return
|
||
|
|
|
||
|
|
# init database
|
||
|
|
db = Database()
|
||
|
|
|
||
|
|
try:
|
||
|
|
if args.command == 'scout':
|
||
|
|
cmd_scout(args, db)
|
||
|
|
elif args.command == 'match':
|
||
|
|
cmd_match(args, db)
|
||
|
|
elif args.command == 'intro':
|
||
|
|
cmd_intro(args, db)
|
||
|
|
elif args.command == 'review':
|
||
|
|
cmd_review(args, db)
|
||
|
|
elif args.command == 'send':
|
||
|
|
cmd_send(args, db)
|
||
|
|
elif args.command == 'status':
|
||
|
|
cmd_status(args, db)
|
||
|
|
elif args.command == 'daemon':
|
||
|
|
cmd_daemon(args, db)
|
||
|
|
elif args.command == 'user':
|
||
|
|
cmd_user(args, db)
|
||
|
|
elif args.command == 'me':
|
||
|
|
cmd_me(args, db)
|
||
|
|
elif args.command == 'lost':
|
||
|
|
cmd_lost(args, db)
|
||
|
|
finally:
|
||
|
|
db.close()
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
main()
|