import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:result_monad/result_monad.dart'; import 'models/TimelineIdentifiers.dart'; import 'models/credentials.dart'; import 'models/exec_error.dart'; import 'models/timeline_entry.dart'; import 'models/user_notification.dart'; import 'serializers/friendica/notification_friendica_extension.dart'; import 'serializers/mastodon/timeline_entry_mastodon_extensions.dart'; class FriendicaClient { static final _logger = Logger('$FriendicaClient'); final Credentials _credentials; late final String _authHeader; String get serverName => _credentials.serverName; Credentials get credentials => _credentials; FriendicaClient({required Credentials credentials}) : _credentials = credentials { final authenticationString = "${_credentials.username}:${_credentials.password}"; final encodedAuthString = base64Encode(utf8.encode(authenticationString)); _authHeader = "Basic $encodedAuthString"; } FutureResult, ExecError> getNotifications() async { final url = 'https://$serverName/api/friendica/notifications'; final request = Uri.parse(url); _logger.finest(() => 'Getting new notifications'); return (await _getApiListRequest(request).andThenSuccessAsync( (notificationsJson) async => notificationsJson .map((json) => NotificationFriendicaExtension.fromJson(json)) .toList())) .mapError((error) { if (error is ExecError) { return error; } return ExecError(type: ErrorType.localError, message: error.toString()); }); } FutureResult clearNotifications() async { final url = 'https://$serverName/api/v1/notifications/clear'; final request = Uri.parse(url); _logger.finest(() => 'Clearing unread notifications'); final response = await _postUrl(request, {}); return response.mapValue((value) => true); } FutureResult, ExecError> getUserTimeline( {String userId = '', int page = 1, int count = 10}) async { _logger.finest(() => 'Getting user timeline for $userId'); final baseUrl = 'https://$serverName/api/statuses/user_timeline?'; final pagingData = 'count=$count&page=$page'; final url = userId.isEmpty ? '$baseUrl$pagingData' : '${baseUrl}screen_name=$userId$pagingData'; final request = Uri.parse(url); return (await _getApiListRequest(request).andThenSuccessAsync( (postsJson) async => postsJson .map((json) => TimelineEntryMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error as ExecError); } FutureResult, ExecError> getTimeline( {required TimelineIdentifiers type, int sinceId = 0, int limit = 20}) async { final String timelinePath = _typeToTimelinePath(type); final String timelineQPs = _typeToTimelineQueryParameters(type); final baseUrl = 'https://$serverName/api/v1/timelines/$timelinePath'; final pagingData = sinceId == 0 ? 'limit=$limit' : 'since_id=$sinceId&limit=$limit'; final url = '$baseUrl?$pagingData&$timelineQPs'; final request = Uri.parse(url); _logger.finest( () => 'Getting home timeline limit $limit since $sinceId : $url'); return (await _getApiListRequest(request).andThenSuccessAsync( (postsJson) async => postsJson .map((json) => TimelineEntryMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error as ExecError); } FutureResult, ExecError> getPostOrComment(String id, {bool fullContext = false}) async { _logger.finest( () => 'Getting entry for status $id, full context? $fullContext'); return (await runCatchingAsync(() async { final baseUrl = 'https://$serverName/api/v1/statuses/$id'; final url = fullContext ? '$baseUrl/context' : baseUrl; final request = Uri.parse(url); return (await _getApiRequest(request).andThenSuccessAsync((json) async { if (fullContext) { final ancestors = json['ancestors'] as List; final descendants = json['descendants'] as List; final items = [ ...ancestors .map((a) => TimelineEntryMastodonExtensions.fromJson(a)), ...descendants .map((d) => TimelineEntryMastodonExtensions.fromJson(d)) ]; return items; } else { return [TimelineEntryMastodonExtensions.fromJson(json)]; } })); })) .mapError((error) => ExecError( type: ErrorType.parsingError, message: error.toString(), )); } FutureResult createNewPost(String text) async { _logger.finest(() => 'Creating post'); final url = Uri.parse('https://$serverName/api/v1/statuses'); final body = {'status': text, 'spoiler_text': 'For Testing Only'}; print(body); final result = await _postUrl(url, body); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } FutureResult changeFavoriteStatus( String id, bool status) async { final action = status ? 'favourite' : 'unfavourite'; final url = Uri.parse('https://$serverName/api/v1/statuses/$id/$action'); final result = await _postUrl(url, {}); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } FutureResult getMyProfile() async { _logger.finest(() => 'Getting logged in user profile'); final request = Uri.parse('https://$serverName/api/friendica/profile/show'); return (await _getApiRequest(request)) .mapValue((value) => value.toString()); } FutureResult _getUrl(Uri url) async { try { //SecurityContext.defaultContext.setTrustedCertificates('/etc/apache2/certificate/apache-certificate.crt'); final response = await http.get( url, headers: { 'Authorization': _authHeader, 'Content-Type': 'application/json; charset=UTF-8' }, ); if (response.statusCode != 200) { return Result.error(ExecError( type: ErrorType.authentication, message: '${response.statusCode}: ${response.reasonPhrase}')); } return Result.ok(utf8.decode(response.bodyBytes)); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } FutureResult _postUrl( Uri url, Map body) async { try { final response = await http.post( url, headers: { 'Authorization': _authHeader, 'Content-Type': 'application/json; charset=UTF-8' }, body: jsonEncode(body), ); if (response.statusCode != 200) { return Result.error(ExecError( type: ErrorType.authentication, message: '${response.statusCode}: ${response.reasonPhrase}')); } return Result.ok(response.body); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } FutureResult, ExecError> _getApiListRequest(Uri url) async { return (await _getUrl(url).andThenSuccessAsync( (jsonText) async => jsonDecode(jsonText) as List)) .mapError((error) => error as ExecError); } FutureResult _getApiRequest(Uri url) async { return (await _getUrl(url) .andThenSuccessAsync((jsonText) async => jsonDecode(jsonText))) .mapError((error) => error as ExecError); } String _typeToTimelinePath(TimelineIdentifiers type) { switch (type.timeline) { case TimelineType.home: return 'home'; case TimelineType.global: return 'public'; case TimelineType.local: return 'public'; case TimelineType.tag: case TimelineType.profile: case TimelineType.self: throw UnimplementedError('These types are not supported yet'); } } String _typeToTimelineQueryParameters(TimelineIdentifiers type) { switch (type.timeline) { case TimelineType.home: case TimelineType.global: return ''; case TimelineType.local: return 'local=true'; case TimelineType.tag: case TimelineType.profile: case TimelineType.self: throw UnimplementedError('These types are not supported yet'); } } }