ha-addons/connectd/introd/deliver.py
2025-12-15 11:06:57 -06:00

509 lines
16 KiB
Python

"""
introd/deliver.py - intro delivery via multiple channels
supports:
- email (smtp)
- mastodon dm (if they allow dms)
- bluesky dm (via AT Protocol)
- matrix dm (creates DM room and sends message)
- github issue (opens intro as issue on their most active repo)
- manual queue (for review before sending)
contact method is determined by ACTIVITY-BASED SELECTION:
- picks the platform where the user is MOST ACTIVE
- verified handles (from rel="me" links) get a bonus
NOTE: reddit is NOT a delivery method - it's discovery only.
reddit-discovered users are contacted via their external links.
"""
import os
import json
import smtplib
import requests
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
from pathlib import Path
# config from env - no hardcoded credentials
SMTP_HOST = os.environ.get('SMTP_HOST', '')
SMTP_PORT = int(os.environ.get('SMTP_PORT', 465))
SMTP_USER = os.environ.get('SMTP_USER', '')
SMTP_PASS = os.environ.get('SMTP_PASS', '')
FROM_EMAIL = os.environ.get('FROM_EMAIL', '')
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN', '')
MASTODON_TOKEN = os.environ.get('MASTODON_TOKEN', '')
MASTODON_INSTANCE = os.environ.get('MASTODON_INSTANCE', '')
BLUESKY_HANDLE = os.environ.get('BLUESKY_HANDLE', '')
BLUESKY_APP_PASSWORD = os.environ.get('BLUESKY_APP_PASSWORD', '')
MATRIX_HOMESERVER = os.environ.get('MATRIX_HOMESERVER', '')
MATRIX_USER_ID = os.environ.get('MATRIX_USER_ID', '')
MATRIX_ACCESS_TOKEN = os.environ.get('MATRIX_ACCESS_TOKEN', '')
# delivery log
DELIVERY_LOG = Path(__file__).parent.parent / 'data' / 'delivery_log.json'
MANUAL_QUEUE = Path(__file__).parent.parent / 'data' / 'manual_queue.json'
def load_delivery_log():
"""load delivery history"""
if DELIVERY_LOG.exists():
return json.loads(DELIVERY_LOG.read_text())
return {'sent': [], 'failed': [], 'queued': []}
def save_delivery_log(log):
"""save delivery history"""
DELIVERY_LOG.parent.mkdir(parents=True, exist_ok=True)
DELIVERY_LOG.write_text(json.dumps(log, indent=2))
def load_manual_queue():
"""load manual review queue"""
if MANUAL_QUEUE.exists():
return json.loads(MANUAL_QUEUE.read_text())
return []
def save_manual_queue(queue):
"""save manual review queue"""
MANUAL_QUEUE.parent.mkdir(parents=True, exist_ok=True)
MANUAL_QUEUE.write_text(json.dumps(queue, indent=2))
def already_contacted(recipient_id):
"""check if we've already sent an intro to this person"""
log = load_delivery_log()
sent_ids = [s.get('recipient_id') for s in log.get('sent', [])]
return recipient_id in sent_ids
def send_email(to_email, subject, body, dry_run=False):
"""send email via smtp"""
if dry_run:
print(f" [dry run] would email {to_email}")
print(f" subject: {subject}")
print(f" body preview: {body[:100]}...")
return True, "dry run"
try:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = FROM_EMAIL
msg['To'] = to_email
# plain text
text_part = MIMEText(body, 'plain')
msg.attach(text_part)
# html version (simple)
html_body = body.replace('\n', '<br>')
html_part = MIMEText(f"<html><body><p>{html_body}</p></body></html>", 'html')
msg.attach(html_part)
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) as server:
server.login(SMTP_USER, SMTP_PASS)
server.sendmail(SMTP_USER, to_email, msg.as_string())
return True, None
except Exception as e:
return False, str(e)
def create_github_issue(owner, repo, title, body, dry_run=False):
"""create github issue as intro"""
if not GITHUB_TOKEN:
return False, "GITHUB_TOKEN not set"
if dry_run:
print(f" [dry run] would create issue on {owner}/{repo}")
print(f" title: {title}")
return True, "dry run"
try:
url = f"https://api.github.com/repos/{owner}/{repo}/issues"
resp = requests.post(
url,
headers={
'Authorization': f'token {GITHUB_TOKEN}',
'Accept': 'application/vnd.github.v3+json',
},
json={
'title': title,
'body': body,
'labels': ['introduction', 'community'],
},
timeout=30,
)
if resp.status_code == 201:
issue_url = resp.json().get('html_url')
return True, issue_url
else:
return False, f"github api error: {resp.status_code} - {resp.text}"
except Exception as e:
return False, str(e)
def send_mastodon_dm(recipient_acct, message, dry_run=False):
"""send mastodon direct message"""
if not MASTODON_TOKEN:
return False, "MASTODON_TOKEN not set"
if dry_run:
print(f" [dry run] would DM {recipient_acct}")
print(f" message preview: {message[:100]}...")
return True, "dry run"
try:
# post as direct message (visibility: direct, mention recipient)
url = f"https://{MASTODON_INSTANCE}/api/v1/statuses"
resp = requests.post(
url,
headers={
'Authorization': f'Bearer {MASTODON_TOKEN}',
'Content-Type': 'application/json',
},
json={
'status': f"@{recipient_acct} {message}",
'visibility': 'direct',
},
timeout=30,
)
if resp.status_code in [200, 201]:
return True, resp.json().get('url')
else:
return False, f"mastodon api error: {resp.status_code} - {resp.text}"
except Exception as e:
return False, str(e)
def send_bluesky_dm(recipient_handle, message, dry_run=False):
"""send bluesky direct message via AT Protocol"""
if not BLUESKY_APP_PASSWORD:
return False, "BLUESKY_APP_PASSWORD not set"
if dry_run:
print(f" [dry run] would DM {recipient_handle} on bluesky")
print(f" message preview: {message[:100]}...")
return True, "dry run"
try:
# authenticate with bluesky
auth_url = "https://bsky.social/xrpc/com.atproto.server.createSession"
auth_resp = requests.post(
auth_url,
json={
'identifier': BLUESKY_HANDLE,
'password': BLUESKY_APP_PASSWORD,
},
timeout=30,
)
if auth_resp.status_code != 200:
return False, f"bluesky auth failed: {auth_resp.status_code}"
auth_data = auth_resp.json()
access_token = auth_data.get('accessJwt')
did = auth_data.get('did')
# resolve recipient DID
resolve_url = f"https://bsky.social/xrpc/com.atproto.identity.resolveHandle"
resolve_resp = requests.get(
resolve_url,
params={'handle': recipient_handle.lstrip('@')},
timeout=30,
)
if resolve_resp.status_code != 200:
return False, f"couldn't resolve handle {recipient_handle}"
recipient_did = resolve_resp.json().get('did')
# create chat/DM (using convo namespace)
# first get or create conversation
convo_url = "https://bsky.social/xrpc/chat.bsky.convo.getConvoForMembers"
convo_resp = requests.get(
convo_url,
headers={'Authorization': f'Bearer {access_token}'},
params={'members': [recipient_did]},
timeout=30,
)
if convo_resp.status_code != 200:
# try creating conversation
return False, f"couldn't get/create conversation: {convo_resp.status_code}"
convo_id = convo_resp.json().get('convo', {}).get('id')
# send message
msg_url = "https://bsky.social/xrpc/chat.bsky.convo.sendMessage"
msg_resp = requests.post(
msg_url,
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
},
json={
'convoId': convo_id,
'message': {'text': message},
},
timeout=30,
)
if msg_resp.status_code in [200, 201]:
return True, f"sent to {recipient_handle}"
else:
return False, f"bluesky dm failed: {msg_resp.status_code} - {msg_resp.text}"
except Exception as e:
return False, str(e)
def send_matrix_dm(recipient_mxid, message, dry_run=False):
"""send matrix direct message"""
if not MATRIX_ACCESS_TOKEN:
return False, "MATRIX_ACCESS_TOKEN not set"
if dry_run:
print(f" [dry run] would DM {recipient_mxid} on matrix")
print(f" message preview: {message[:100]}...")
return True, "dry run"
try:
# create or get direct room with recipient
# first, check if we already have a DM room
headers = {'Authorization': f'Bearer {MATRIX_ACCESS_TOKEN}'}
# create a new DM room
create_room_resp = requests.post(
f'{MATRIX_HOMESERVER}/_matrix/client/v3/createRoom',
headers=headers,
json={
'is_direct': True,
'invite': [recipient_mxid],
'preset': 'trusted_private_chat',
},
timeout=30,
)
if create_room_resp.status_code not in [200, 201]:
return False, f"matrix room creation failed: {create_room_resp.status_code} - {create_room_resp.text}"
room_id = create_room_resp.json().get('room_id')
# send message to room
import time
txn_id = str(int(time.time() * 1000))
msg_resp = requests.put(
f'{MATRIX_HOMESERVER}/_matrix/client/v3/rooms/{room_id}/send/m.room.message/{txn_id}',
headers=headers,
json={
'msgtype': 'm.text',
'body': message,
},
timeout=30,
)
if msg_resp.status_code in [200, 201]:
return True, f"sent to {recipient_mxid} in {room_id}"
else:
return False, f"matrix send failed: {msg_resp.status_code} - {msg_resp.text}"
except Exception as e:
return False, str(e)
def add_to_manual_queue(intro_data):
"""add intro to manual review queue"""
queue = load_manual_queue()
queue.append({
**intro_data,
'queued_at': datetime.now().isoformat(),
'status': 'pending',
})
save_manual_queue(queue)
return True
def determine_best_contact(human):
"""
determine best contact method based on WHERE THEY'RE MOST ACTIVE
uses activity-based selection from groq_draft module
"""
from introd.groq_draft import determine_contact_method as activity_based_contact
method, info = activity_based_contact(human)
# convert github_issue info to dict format for delivery
if method == 'github_issue' and isinstance(info, str) and '/' in info:
parts = info.split('/', 1)
return method, {'owner': parts[0], 'repo': parts[1]}
return method, info
def deliver_intro(match_data, intro_draft, dry_run=False):
"""
deliver an intro via the best available method
match_data: {human_a, human_b, overlap_score, overlap_reasons}
intro_draft: the text to send (from groq)
"""
recipient = match_data.get('human_b', {})
recipient_id = f"{recipient.get('platform')}:{recipient.get('username')}"
# check if already contacted
if already_contacted(recipient_id):
return False, "already contacted", None
# determine contact method
method, contact_info = determine_best_contact(recipient)
log = load_delivery_log()
result = {
'recipient_id': recipient_id,
'recipient_name': recipient.get('name') or recipient.get('username'),
'method': method,
'contact_info': contact_info,
'overlap_score': match_data.get('overlap_score'),
'timestamp': datetime.now().isoformat(),
}
success = False
error = None
if method == 'email':
subject = f"someone you might want to know - connectd"
success, error = send_email(contact_info, subject, intro_draft, dry_run)
elif method == 'mastodon':
success, error = send_mastodon_dm(contact_info, intro_draft, dry_run)
elif method == 'bluesky':
success, error = send_bluesky_dm(contact_info, intro_draft, dry_run)
elif method == 'matrix':
success, error = send_matrix_dm(contact_info, intro_draft, dry_run)
elif method == 'discord':
from scoutd.discord import send_discord_dm
success, error = send_discord_dm(contact_info, intro_draft, dry_run)
elif method == 'lemmy':
from scoutd.lemmy import send_lemmy_dm
success, error = send_lemmy_dm(contact_info, intro_draft, dry_run)
elif method == 'github_issue':
owner = contact_info.get('owner')
repo = contact_info.get('repo')
title = "community introduction from connectd"
# format for github
github_body = f"""hey {recipient.get('name') or recipient.get('username')},
{intro_draft}
---
*this is an automated introduction from [connectd](https://github.com/connectd-daemon), a daemon that finds isolated builders with aligned values and connects them. if this feels spammy, i apologize - you can close this issue and we won't reach out again.*
"""
success, error = create_github_issue(owner, repo, title, github_body, dry_run)
elif method == 'manual':
# add to review queue
add_to_manual_queue({
'match': match_data,
'draft': intro_draft,
'recipient': recipient,
})
success = True
error = "added to manual queue"
# log result
result['success'] = success
result['error'] = error
if success:
log['sent'].append(result)
else:
log['failed'].append(result)
save_delivery_log(log)
return success, error, method
def deliver_batch(matches_with_intros, dry_run=False):
"""
deliver intros for a batch of matches
matches_with_intros: list of {match_data, intro_draft}
"""
results = []
for item in matches_with_intros:
match_data = item.get('match_data') or item.get('match')
intro_draft = item.get('intro_draft') or item.get('draft')
if not match_data or not intro_draft:
continue
success, error, method = deliver_intro(match_data, intro_draft, dry_run)
results.append({
'recipient': match_data.get('human_b', {}).get('username'),
'method': method,
'success': success,
'error': error,
})
print(f" {match_data.get('human_b', {}).get('username')}: {method} - {'ok' if success else error}")
return results
def get_delivery_stats():
"""get delivery statistics"""
log = load_delivery_log()
queue = load_manual_queue()
return {
'sent': len(log.get('sent', [])),
'failed': len(log.get('failed', [])),
'queued': len(log.get('queued', [])),
'manual_pending': len([q for q in queue if q.get('status') == 'pending']),
'by_method': {
'email': len([s for s in log.get('sent', []) if s.get('method') == 'email']),
'mastodon': len([s for s in log.get('sent', []) if s.get('method') == 'mastodon']),
'github_issue': len([s for s in log.get('sent', []) if s.get('method') == 'github_issue']),
'manual': len([s for s in log.get('sent', []) if s.get('method') == 'manual']),
},
}
def review_manual_queue():
"""review and process manual queue"""
queue = load_manual_queue()
pending = [q for q in queue if q.get('status') == 'pending']
if not pending:
print("no items in manual queue")
return
print(f"\n{len(pending)} items pending review:\n")
for i, item in enumerate(pending, 1):
recipient = item.get('recipient', {})
match = item.get('match', {})
print(f"[{i}] {recipient.get('name') or recipient.get('username')}")
print(f" platform: {recipient.get('platform')}")
print(f" url: {recipient.get('url')}")
print(f" overlap: {match.get('overlap_score')}")
print(f" draft preview: {item.get('draft', '')[:80]}...")
print()
return pending