## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later import json import logging from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen from backend.app.plugins.base import BasePlugin DEFAULT_POST_PREFIX = 'From my #LinkLog: ' logger = logging.getLogger(__name__) class DefaultFrontendPlugin(BasePlugin): name = 'default_frontend' version = '1.0.0' def handle_event(self, event): return {'status': 'accepted', 'plugin': self.name, 'event': event.get('type')} class MastodonPlugin(BasePlugin): name = 'mastodon' version = '1.0.0' enabled = False config = {} def initialize(self, config=None): self.config = config or {} return True def handle_event(self, event): config = dict(self.config) user_id = event.get('user_id') if user_id: from backend.app.database import get_connection with get_connection() as conn: row = conn.execute( ''' SELECT config FROM user_plugin_config WHERE user_id = ? AND plugin_name = ? ''', (user_id, self.name), ).fetchone() if row and row['config']: config.update(json.loads(row['config'])) instance = str(config.get('instance', '')).strip().rstrip('/') if instance and '://' not in instance: instance = f'https://{instance}' access_token = str(config.get('access_token', '')).strip() if not instance or not access_token: logger.debug( 'Mastodon post skipped: instance_configured=%s token_configured=%s user_id=%s', bool(instance), bool(access_token), user_id, ) return {'status': 'skipped', 'plugin': self.name, 'reason': 'not_configured'} status_parts = [event.get('title') or event.get('url', '')] if event.get('comment'): status_parts.append(event['comment']) post_prefix = config.get('post_prefix') if post_prefix is None and config.get('hashtag'): post_prefix = f'#{str(config["hashtag"]).strip().lstrip("#")} ' post_prefix = str(post_prefix if post_prefix is not None else DEFAULT_POST_PREFIX) status = f'{post_prefix}{event.get("url", "")}'.strip() if event.get('tags'): status = f'{status} {" ".join(event["tags"])}' status_parts.append(status) post_body = '\n'.join(status_parts) endpoint = f'{instance}/api/v1/statuses' logger.debug( 'Posting link to Mastodon: endpoint=%s user_id=%s event_id=%s body_length=%d', endpoint, user_id, event.get('id'), len(post_body), ) try: request = Request( endpoint, data=urlencode({'status': post_body}).encode('utf-8'), headers={ 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'User-Agent': 'LinkLog/1.0', }, method='POST', ) with urlopen(request, timeout=5) as response: response_body = response.read().decode('utf-8') logger.debug( 'Mastodon post response: endpoint=%s status=%s body_length=%d', endpoint, response.status, len(response_body), ) response_data = json.loads(response_body) logger.info('Mastodon post succeeded: instance=%s user_id=%s post_id=%s', instance, user_id, response_data.get('id')) return { 'status': 'posted', 'plugin': self.name, 'post_id': response_data.get('id'), } except HTTPError as error: response_body = error.read().decode('utf-8', errors='replace') logger.warning( 'Mastodon post failed: endpoint=%s user_id=%s status=%s response=%s', endpoint, user_id, error.code, response_body[:500], ) return { 'status': 'failed', 'plugin': self.name, 'reason': f'HTTP {error.code}: {response_body[:500]}', } except (URLError, TimeoutError, OSError, ValueError) as error: logger.exception('Mastodon post failed: endpoint=%s user_id=%s error=%s', endpoint, user_id, error) return { 'status': 'failed', 'plugin': self.name, 'reason': str(error), } class PluginManager: def __init__(self): self.plugins = [DefaultFrontendPlugin(), MastodonPlugin()] def refresh_from_db(self): from backend.app.database import get_connection with get_connection() as conn: rows = conn.execute('SELECT name, enabled, config FROM plugins').fetchall() enabled_names = {row['name'] for row in rows if row['enabled']} for plugin in self.plugins: plugin.enabled = plugin.name in enabled_names row = next((item for item in rows if item['name'] == plugin.name), None) plugin.initialize(json.loads(row['config']) if row and row['config'] else {}) return enabled_names def dispatch(self, event): self.refresh_from_db() results = [] for plugin in self.plugins: if plugin.enabled: result = plugin.handle_event(event) results.append(result) logger.debug('Plugin dispatch result: plugin=%s event_id=%s result=%s', plugin.name, event.get('id'), result) return results plugin_manager = PluginManager()