diff --git a/.env.example b/.env.example index 96bb393..79f362e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/config.py b/config.py index 828b4ed..439e96e 100644 --- a/config.py +++ b/config.py @@ -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')) diff --git a/introd/deliver.py b/introd/deliver.py index ae63874..c261f46 100644 --- a/introd/deliver.py +++ b/introd/deliver.py @@ -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') diff --git a/introd/groq_draft.py b/introd/groq_draft.py index 2f7f66c..26ed004 100644 --- a/introd/groq_draft.py +++ b/introd/groq_draft.py @@ -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']) diff --git a/scoutd/lemmy.py b/scoutd/lemmy.py index b2aec09..ccf51ab 100644 --- a/scoutd/lemmy.py +++ b/scoutd/lemmy.py @@ -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: