add lemmy authentication and DM delivery support

- add LEMMY_INSTANCE, LEMMY_USERNAME, LEMMY_PASSWORD env vars
- add send_lemmy_dm() for private message delivery
- add lemmy as activity-based contact method
- user's instance prioritized in scraping

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2025-12-15 09:44:56 -06:00
parent 582475457c
commit a5d7970b29
5 changed files with 138 additions and 2 deletions

View file

@ -26,6 +26,11 @@ MATRIX_ACCESS_TOKEN=
DISCORD_BOT_TOKEN=
DISCORD_TARGET_SERVERS= # comma separated server IDs
# lemmy (for authenticated access to your instance)
LEMMY_INSTANCE=
LEMMY_USERNAME=
LEMMY_PASSWORD=
# === EMAIL DELIVERY ===
SMTP_HOST=
SMTP_PORT=465

View file

@ -83,6 +83,11 @@ MATRIX_ACCESS_TOKEN = os.environ.get('MATRIX_ACCESS_TOKEN', '')
DISCORD_BOT_TOKEN = os.environ.get('DISCORD_BOT_TOKEN', '')
DISCORD_TARGET_SERVERS = os.environ.get('DISCORD_TARGET_SERVERS', '')
# lemmy (for authenticated access to private instance)
LEMMY_INSTANCE = os.environ.get('LEMMY_INSTANCE', '')
LEMMY_USERNAME = os.environ.get('LEMMY_USERNAME', '')
LEMMY_PASSWORD = os.environ.get('LEMMY_PASSWORD', '')
# email (for sending intros)
SMTP_HOST = os.environ.get('SMTP_HOST', '')
SMTP_PORT = int(os.environ.get('SMTP_PORT', '465'))

View file

@ -395,6 +395,10 @@ def deliver_intro(match_data, intro_draft, dry_run=False):
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')

View file

@ -187,6 +187,32 @@ def determine_contact_method(human):
if matrix_score > 0:
activity_scores['matrix'] = {'score': matrix_score, 'info': matrix_id}
# lemmy activity (fediverse)
lemmy_username = human.get('username') if human.get('platform') == 'lemmy' else extra.get('lemmy')
if lemmy_username:
lemmy_score = 0
# lemmy is fediverse - high values alignment
lemmy_score += 20 # fediverse platform bonus
post_count = extra.get('post_count', 0)
comment_count = extra.get('comment_count', 0)
if post_count > 100:
lemmy_score += 15
elif post_count > 50:
lemmy_score += 10
elif post_count > 10:
lemmy_score += 5
if comment_count > 500:
lemmy_score += 10
elif comment_count > 100:
lemmy_score += 5
if lemmy_score > 0:
activity_scores['lemmy'] = {'score': lemmy_score, 'info': lemmy_username}
# pick highest activity platform
if activity_scores:
best_platform = max(activity_scores.items(), key=lambda x: x[1]['score'])

View file

@ -7,7 +7,7 @@ great for finding lost builders in communities like:
- /c/antiwork, /c/workreform (lost builders!)
- /c/selfhosted, /c/privacy, /c/opensource
no auth needed for public posts.
supports authenticated access for private instances and DM delivery.
"""
import requests
@ -24,6 +24,14 @@ from .lost import (
classify_user,
)
# auth config from environment
LEMMY_INSTANCE = os.environ.get('LEMMY_INSTANCE', '')
LEMMY_USERNAME = os.environ.get('LEMMY_USERNAME', '')
LEMMY_PASSWORD = os.environ.get('LEMMY_PASSWORD', '')
# auth token cache
_auth_token = None
# popular lemmy instances
LEMMY_INSTANCES = [
'lemmy.ml',
@ -60,6 +68,89 @@ CACHE_DIR = Path(__file__).parent.parent / 'db' / 'cache' / 'lemmy'
CACHE_DIR.mkdir(parents=True, exist_ok=True)
def get_auth_token(instance=None):
"""get auth token for lemmy instance"""
global _auth_token
if _auth_token:
return _auth_token
instance = instance or LEMMY_INSTANCE
if not all([instance, LEMMY_USERNAME, LEMMY_PASSWORD]):
return None
try:
url = f"https://{instance}/api/v3/user/login"
resp = requests.post(url, json={
'username_or_email': LEMMY_USERNAME,
'password': LEMMY_PASSWORD,
}, timeout=30)
if resp.status_code == 200:
_auth_token = resp.json().get('jwt')
return _auth_token
return None
except Exception as e:
print(f"lemmy auth error: {e}")
return None
def send_lemmy_dm(recipient_username, message, dry_run=False):
"""send a private message via lemmy"""
if not LEMMY_INSTANCE:
return False, "LEMMY_INSTANCE not configured"
if dry_run:
print(f"[dry run] would send lemmy DM to {recipient_username}")
return True, None
token = get_auth_token()
if not token:
return False, "failed to authenticate with lemmy"
try:
# parse recipient - could be username@instance or just username
if '@' in recipient_username:
username, instance = recipient_username.split('@', 1)
else:
username = recipient_username
instance = LEMMY_INSTANCE
# get recipient user id
user_url = f"https://{LEMMY_INSTANCE}/api/v3/user"
resp = requests.get(user_url, params={'username': f"{username}@{instance}"}, timeout=30)
if resp.status_code != 200:
# try without instance suffix for local users
resp = requests.get(user_url, params={'username': username}, timeout=30)
if resp.status_code != 200:
return False, f"could not find user {recipient_username}"
recipient_id = resp.json().get('person_view', {}).get('person', {}).get('id')
if not recipient_id:
return False, "could not get recipient id"
# send DM
dm_url = f"https://{LEMMY_INSTANCE}/api/v3/private_message"
resp = requests.post(dm_url,
headers={'Authorization': f'Bearer {token}'},
json={
'content': message,
'recipient_id': recipient_id,
},
timeout=30
)
if resp.status_code == 200:
return True, None
else:
return False, f"lemmy DM error: {resp.status_code} - {resp.text}"
except Exception as e:
return False, f"lemmy DM error: {str(e)}"
def get_community_posts(instance, community, limit=50, sort='New'):
"""get posts from a lemmy community"""
try:
@ -167,7 +258,12 @@ def scrape_lemmy(db, limit_per_community=30):
lost_found = 0
seen_users = set()
for instance in LEMMY_INSTANCES:
# build instance list - user's instance first if configured
instances = list(LEMMY_INSTANCES)
if LEMMY_INSTANCE and LEMMY_INSTANCE not in instances:
instances.insert(0, LEMMY_INSTANCE)
for instance in instances:
print(f" instance: {instance}")
for community in TARGET_COMMUNITIES: