diff --git a/bin/server.dart b/bin/server.dart index 14d81eb..c9e82df 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -1,198 +1,109 @@ import 'dart:convert'; import 'dart:io'; - -import 'package:shelf/shelf.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:shelf_router/shelf_router.dart'; +import 'dart:math'; import 'package:dio/dio.dart' as dio_lib; var dio = dio_lib.Dio(); -// Load confirmation token from env -final confirmationToken = Platform.environment['CONFIRMATION_TOKEN']; -// Load secret key from env -final secretKey = Platform.environment['SECRET_KEY']; // Load mastodon instance url from env final instanceUrl = Platform.environment['INSTANCE_URL']; // Load mastodon auth token from env final authToken = Platform.environment['MASTODON_TOKEN']; -// Configure routes. -final _router = Router() - ..get('/', _rootHandler) - ..post('/vk', _vkHandler); - -Response _rootHandler(Request req) { - return Response.ok('Hello, World!\n'); -} - -Future _vkHandler(Request req) async { - // POST request should contain a JSON body. - // JSON must contain a 'type' field. - // The value of the 'type' field must be one of the following: - // 'confirmation' - // 'wall_post_new' - // If we recieve a 'confirmation' request, we should respond with a confirmationToken. - // If we recieve a 'wall_post_new' request, we should validate if 'secret' field equals - // secretKey and respond with an 'ok' string, printing request in the console. - - final requestBody = await req.readAsString(); - final requestJson = json.decode(requestBody); - final requestType = requestJson['type']; - - if (requestType == 'confirmation') { - return Response.ok(confirmationToken); - } else if (requestType == 'wall_post_new') { - if (requestJson['secret'] == secretKey) { - print(requestBody); - _postOnMastodon(requestJson); - return Response.ok('ok'); - } else { - return Response.ok('invalid secret'); - } - } else { - return Response.ok('invalid request type'); - } -} - -Future _postOnMastodon(reqJson) async { - // Called to repost the post on Mastodon on vk. - // reqJson contains the 'object' field of the VK post. - // This object has the 'text' field with the post text. - // Also it contains the 'attachments' field with the attachments. - // The attachments field is an array of objects. - // Each object has the 'type' field with the attachment type. - // We only want to post photos. - // The 'photo' type is 'photo'. - // The 'photo' type has the 'sizes' field with an array of objects. - // Each object has the 'url', 'height' and 'width' fields. - // We want to post the largest photo. - // The 'url' field is the url of the photo. - // The 'height' and 'width' fields are the height and width of the photo. - // We download all pictures with dio into a temporary directory. - - // Make sure post text is 500 characters or less. - String postText = reqJson['object']['text']; - if (postText.length > 500) { - postText.substring(0, 500); - } - - final attachments = reqJson['object']['attachments'] ?? []; - - var photos = attachments.where((attachment) => attachment['type'] == 'photo'); - - List photoUrls = []; - - for (final photo in photos) { - // Find the url of the largest photo by comparing the height and width. - var largestPhoto = photo['photo']['sizes'].reduce((a, b) { - if (a['height'] * a['width'] > b['height'] * b['width']) { - return a; - } else { - return b; - } - }); - final photoUrl = largestPhoto['url']; - final photoFilename = - '${DateTime.now().millisecondsSinceEpoch}-${photoUrl.split('/').last.split('?').first.substring(0, 10)}.jpg'; - final photoPath = '${Directory.systemTemp.path}/$photoFilename'; - await dio.download(photoUrl, photoPath); - photoUrls.add(photoPath); - } - - // If there are more than four photos, move other photos to another array to be posted in the thread. - List otherPhotos = []; - if (photoUrls.length > 4) { - otherPhotos = photoUrls.sublist(4); - photoUrls = photoUrls.sublist(0, 4); - } - - print('photoUrls: $photoUrls'); - print('otherPhotos: $otherPhotos'); - - // Post the post on Mastodon. - // First we upload all photos with dio. - // Then we post the post with the uploaded photos and the text. - // We delete the photos after posting. - // Photos are uploaded to POST /api/v1/media in the 'file' form data field. - // We take the 'id' field of the response and add it to the post's 'media_ids' field. - // The 'media_ids' field is an array of media ids. - // We post the post with the media ids and the text in the 'status' field. - // Endpoint: POST /api/v1/statuses - // Headers: Authorization: Bearer - // We use auth on all requests. - - // if there are no photos, we don't fetch them - if (photos.isEmpty) { - await dio.post('$instanceUrl/api/v1/statuses', +Future _postOnMastodon(postText) async { + await dio.post('$instanceUrl/api/v1/statuses', data: {'status': postText}, options: dio_lib.Options(headers: {'Authorization': 'Bearer $authToken'})); - } else { - var mediaIds = []; +} - for (final photoUrl in photoUrls) { - final resp = await dio.post('$instanceUrl/api/v1/media', - data: dio_lib.FormData.fromMap( - {'file': dio_lib.MultipartFile.fromFileSync(photoUrl)}), - options: - dio_lib.Options(headers: {'Authorization': 'Bearer $authToken'})); - mediaIds.add(resp.data['id']); - } +/// Reads a file [fileUri] and adds posts to posts.json +/// +/// If [fileUri] does not exist, do nothing. +/// [fileUri] is a plain text file where the posts are separated by empty lines. +/// posts.json is an array of posts +Future _importPosts(fileUri) async { + if (!File(fileUri).existsSync()) { + return; + } - var postResponse = await dio.post('$instanceUrl/api/v1/statuses', - data: {'status': postText, 'media_ids': mediaIds}, - options: - dio_lib.Options(headers: {'Authorization': 'Bearer $authToken'})); + final file = File(fileUri); + final lines = await file.readAsLines(); - await Future.wait(photoUrls.map((photoUrl) => File(photoUrl).delete())); + final List posts = []; + String post = ''; - // If there are other photos, post them in a thread. - while (otherPhotos.isNotEmpty) { - mediaIds = []; - if (otherPhotos.length > 4) { - otherPhotos = photoUrls.sublist(4); - photoUrls = photoUrls.sublist(0, 4); - } else { - photoUrls = otherPhotos; - otherPhotos = []; - } - - print('photoUrls: $photoUrls'); - print('otherPhotos: $otherPhotos'); - - for (final photoUrl in photoUrls) { - final resp = await dio.post('$instanceUrl/api/v1/media', - data: dio_lib.FormData.fromMap( - {'file': dio_lib.MultipartFile.fromFileSync(photoUrl)}), - options: dio_lib.Options( - headers: {'Authorization': 'Bearer $authToken'})); - mediaIds.add(resp.data['id']); - } - - postResponse = await dio.post('$instanceUrl/api/v1/statuses', - data: { - 'in_reply_to_id': postResponse.data['id'], - 'media_ids': mediaIds - }, - options: - dio_lib.Options(headers: {'Authorization': 'Bearer $authToken'})); - - await Future.wait(photoUrls.map((photoUrl) => File(photoUrl).delete())); + for (final line in lines) { + if (line.isEmpty && post != '') { + posts.add(post); + } else { + post += '$line\n'; } } + + if (post != '') { + posts.add(post); + } + + // Read posts.json and add posts to it + final postsJson = json.decode(await File('posts.json').readAsString()) as List; + postsJson.addAll(posts); + + // Write posts.json + await File('posts.json').writeAsString(json.encode(postsJson)); + + // Delete file + await file.delete(); + + return; + +} + +/// Posts a random string from posts.json to mastodon +/// +/// If posts.json does not exist, do nothing. +/// posts.json is an array of posts +/// If an array is empty, do nothing. +/// Delete posted post from posts.json +Future _postRandomPost() async { + if (!File('posts.json').existsSync()) { + return; + } + + final postsJson = json.decode(await File('posts.json').readAsString()) as List; + + if (postsJson.isEmpty) { + return; + } + + final post = postsJson.removeAt(Random().nextInt(postsJson.length)); + + await File('posts.json').writeAsString(json.encode(postsJson)); + + await _postOnMastodon(post); + + return; } void main(List args) async { - // Use any available host or container IP (usually `0.0.0.0`). - final ip = InternetAddress.anyIPv4; + // If posts.json does not exist, create it and write an empty array + if (!File('posts.json').existsSync()) { + await File('posts.json').writeAsString(json.encode([])); + } - // Configure a pipeline that logs requests. - final _handler = Pipeline().addMiddleware(logRequests()).addHandler(_router); + // Import posts from files + await _importPosts('/posts.txt'); - // For running in containers, we respect the PORT environment variable. - final port = int.parse(Platform.environment['PORT'] ?? '8080'); - final server = await serve(_handler, ip, port); - print('Server listening on port ${server.port}'); + // Post a random post + await _postRandomPost(); + + // Post a random post at random time + // Random time is between 1 and 8 hours + // Random time is choosen after every post + while (true) { + await Future.delayed(Duration(hours: Random().nextInt(8) + 1)); + await _postRandomPost(); + } + }