diff --git a/lib/pleroma/web/akkoma_compat_controller.ex b/lib/pleroma/web/akkoma_compat_controller.ex new file mode 100644 index 0000000000..57abc29d05 --- /dev/null +++ b/lib/pleroma/web/akkoma_compat_controller.ex @@ -0,0 +1,100 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AkkomaCompatController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Language.Translation + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(:skip_auth when action == :translation_languages) + + plug( + OAuthScopesPlug, + %{fallback: :proceed_unauthenticated, scopes: ["read:statuses"]} when action == :translate + ) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AkkomaCompatOperation + + @doc "GET /api/v1/akkoma/translation/languages" + def translation_languages(conn, _params) do + with {:enabled, true} <- {:enabled, Translation.configured?()}, + {:ok, source_languages} <- Translation.supported_languages(:source), + {:ok, target_languages} <- Translation.supported_languages(:target) do + source_languages = + source_languages + |> Enum.map(fn lang -> %{code: lang, name: lang} end) + + target_languages = + target_languages + |> Enum.map(fn lang -> %{code: lang, name: lang} end) + + conn + |> json(%{source: source_languages, target: target_languages}) + else + {:enabled, false} -> + json(conn, %{}) + + e -> + {:error, e} + end + end + + @doc "GET /api/v1/statuses/:id/translations/:language" + def translate( + %{ + assigns: %{user: user}, + private: %{open_api_spex: %{params: %{id: status_id} = params}} + } = conn, + _ + ) do + with {:authentication, true} <- + {:authentication, + !is_nil(user) || + Pleroma.Config.get([Translation, :allow_unauthenticated])}, + %Activity{object: object} <- Activity.get_by_id_with_object(status_id), + {:visibility, visibility} when visibility in ["public", "unlisted"] <- + {:visibility, Visibility.get_visibility(object)}, + {:allow_remote, true} <- + {:allow_remote, + Object.local?(object) || + Pleroma.Config.get([Translation, :allow_remote])}, + {:language, language} when is_binary(language) <- + {:language, Map.get(params, :language) || user.language}, + {:ok, result} <- + Translation.translate( + object.data["content"], + object.data["language"], + language + ) do + json(conn, %{detected_language: result.detected_source_language, text: result.content}) + else + {:authentication, false} -> + render_error(conn, :unauthorized, "Authorization is required to translate statuses") + + {:allow_remote, false} -> + render_error(conn, :bad_request, "You can't translate remote posts") + + {:language, nil} -> + render_error(conn, :bad_request, "Language not specified") + + {:visibility, _} -> + render_error(conn, :not_found, "Record not found") + + {:error, :not_found} -> + render_error(conn, :not_found, "Translation service not configured") + + {:error, error} when error in [:unexpected_response, :quota_exceeded, :too_many_requests] -> + render_error(conn, :service_unavailable, "Translation service not available") + + nil -> + render_error(conn, :not_found, "Record not found") + end + end +end diff --git a/lib/pleroma/web/api_spec/operations/akkoma_compat_operation.ex b/lib/pleroma/web/api_spec/operations/akkoma_compat_operation.ex new file mode 100644 index 0000000000..83d29caf9b --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/akkoma_compat_operation.ex @@ -0,0 +1,91 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AkkomaCompatOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + # Adapted from https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/lib/pleroma/web/api_spec/operations/translate_operation.ex + def translation_languages_operation() do + %Operation{ + tags: ["Akkoma compatibility routes"], + summary: "Get translation languages", + description: "Retreieve a list of supported source and target language", + operationId: "AkkomaCompatController.translation_languages", + responses: %{ + 200 => + Operation.response( + "Translation languages", + "application/json", + source_dest_languages_schema() + ) + } + } + end + + defp source_dest_languages_schema do + %Schema{ + type: :object, + required: [:source, :target], + properties: %{ + source: languages_schema(), + target: languages_schema() + } + } + end + + defp languages_schema do + %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + code: %Schema{type: :string}, + name: %Schema{type: :string} + } + } + } + end + + def translate_operation() do + %Operation{ + tags: ["Akkoma compatibility routes"], + summary: "Translate status", + description: "Translate status with an external API", + operationId: "AkkomaCompatController.translate", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter(:id, :path, FlakeID.schema(), "Status ID", + example: "9umDrYheeY451cQnEe", + required: true + ), + Operation.parameter(:language, :path, :string, "Target language code", example: "en"), + Operation.parameter(:from, :query, :string, "Source language code (unused)", + example: "en" + ) + ], + responses: %{ + 200 => + Operation.response( + "Translated status", + "application/json", + %Schema{ + type: :object, + required: [:detected_language, :text], + properties: %{ + detected_language: %Schema{type: :string}, + text: %Schema{type: :string} + } + } + ) + } + } + end +end diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 81ee1a0a6f..8d0171bf3d 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -182,7 +182,11 @@ def features do end, "events", "multitenancy", - "pleroma:bites" + "pleroma:bites", + # Akkoma compatibility + if Pleroma.Language.Translation.configured?() do + "akkoma:machine_translation" + end ] |> Enum.filter(& &1) end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 9bb3e07ba9..8fcabcd20d 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -718,6 +718,13 @@ defmodule Pleroma.Web.Router do end end + scope "/", Pleroma.Web do + pipe_through(:api) + + get("/api/v1/akkoma/translation/languages", AkkomaCompatController, :translation_languages) + get("/api/v1/statuses/:id/translations/:language", AkkomaCompatController, :translate) + end + scope "/api/v1", Pleroma.Web.MastodonAPI do pipe_through(:authenticated_api) diff --git a/test/pleroma/web/akkoma_compat_controller_test.exs b/test/pleroma/web/akkoma_compat_controller_test.exs new file mode 100644 index 0000000000..02b691ca0f --- /dev/null +++ b/test/pleroma/web/akkoma_compat_controller_test.exs @@ -0,0 +1,60 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AkkomaCompatControllerTest do + use Pleroma.Web.ConnCase, async: true + + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "translation_languages" do + test "returns supported languages list", %{conn: conn} do + clear_config([Pleroma.Language.Translation, :provider], TranslationMock) + + assert %{ + "source" => [%{"code" => "en", "name" => "en"}, %{"code" => "pl", "name" => "pl"}], + "target" => [%{"code" => "en", "name" => "en"}, %{"code" => "pl", "name" => "pl"}] + } = + conn + |> get("/api/v1/akkoma/translation/languages") + |> json_response_and_validate_schema(200) + end + + test "returns empty object when disabled", %{conn: conn} do + clear_config([Pleroma.Language.Translation, :provider], nil) + + assert %{} == + conn + |> get("/api/v1/akkoma/translation/languages") + |> json_response(200) + end + end + + describe "translate" do + test "it translates a status to given language" do + clear_config([Pleroma.Language.Translation, :provider], TranslationMock) + + %{conn: conn} = oauth_access(["read:statuses"]) + another_user = insert(:user) + + {:ok, activity} = + CommonAPI.post(another_user, %{ + status: "Cześć!", + visibility: "public", + language: "pl" + }) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/translations/en") + |> json_response_and_validate_schema(200) + + assert response == %{ + "text" => "!ćśezC", + "detected_language" => "pl" + } + end + end +end