Automatic status translation (#187)
authorfloatingghost <hannah@coffee-and-dreams.uk>
Mon, 29 Aug 2022 19:42:22 +0000 (19:42 +0000)
committerfloatingghost <hannah@coffee-and-dreams.uk>
Mon, 29 Aug 2022 19:42:22 +0000 (19:42 +0000)
Fixes #115

Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk>
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma/pulls/187

15 files changed:
CHANGELOG.md
config/config.exs
config/description.exs
docs/docs/configuration/cheatsheet.md
lib/pleroma/akkoma/translators/deepl.ex [new file with mode: 0644]
lib/pleroma/akkoma/translators/libre_translate.ex [new file with mode: 0644]
lib/pleroma/akkoma/translators/translator.ex [new file with mode: 0644]
lib/pleroma/application.ex
lib/pleroma/web/api_spec/operations/status_operation.ex
lib/pleroma/web/mastodon_api/controllers/status_controller.ex
lib/pleroma/web/mastodon_api/views/instance_view.ex
lib/pleroma/web/router.ex
test/pleroma/translators/deepl_test.exs [new file with mode: 0644]
test/pleroma/translators/libre_translate_test.exs [new file with mode: 0644]
test/pleroma/web/mastodon_api/controllers/status_controller_test.exs

index 1a71255ff969c8adb12a80513faf7875ab4b540a..05cb69c40f6e9044e80971c01c40deac44118284 100644 (file)
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - support for setting instance languages in metadata
 - support for reusing oauth tokens, and not requiring new authorizations
 - the ability to obfuscate domains in your MRF descriptions
+- automatic translation of statuses via DeepL or LibreTranslate
 
 ### Changed
 - MFM parsing is now done on the backend by a modified version of ilja's parser -> https://akkoma.dev/AkkomaGang/mfm-parser
index 5ae7a33a2a83c58911146d6ba0bafa8c2144601e..330e572fe8af04170f7f911a4b02ab2f40df7a96 100644 (file)
@@ -843,6 +843,19 @@ config :pleroma, Pleroma.Search.Elasticsearch.Cluster,
     }
   }
 
+config :pleroma, :translator,
+  enabled: false,
+  module: Akkoma.Translators.DeepL
+
+config :pleroma, :deepl,
+  # either :free or :pro
+  tier: :free,
+  api_key: ""
+
+config :pleroma, :libre_translate,
+  url: "http://127.0.0.1:5000",
+  api_key: nil
+
 # Import environment specific config. This must remain at the bottom
 # of this file so it overrides the configuration defined above.
 import_config "#{Mix.env()}.exs"
index 61ef8f4493c81ac3047effb03d0d091a16026033..a17897b98a08f0cbd70159ee781b498e5008b6f6 100644 (file)
@@ -3226,13 +3226,14 @@ config :pleroma, :config_description, [
     group: :pleroma,
     key: Pleroma.Search,
     type: :group,
+    label: "Search",
     description: "General search settings.",
     children: [
       %{
         key: :module,
-        type: :keyword,
+        type: :module,
         description: "Selected search module.",
-        suggestion: [Pleroma.Search.DatabaseSearch, Pleroma.Search.Meilisearch]
+        suggestions: {:list_behaviour_implementations, Pleroma.Search.SearchBackend}
       }
     ]
   },
@@ -3257,7 +3258,7 @@ config :pleroma, :config_description, [
       },
       %{
         key: :initial_indexing_chunk_size,
-        type: :int,
+        type: :integer,
         description:
           "Amount of posts in a batch when running the initial indexing operation. Should probably not be more than 100000" <>
             " since there's a limit on maximum insert size",
@@ -3268,6 +3269,7 @@ config :pleroma, :config_description, [
   %{
     group: :pleroma,
     key: Pleroma.Search.Elasticsearch.Cluster,
+    label: "Elasticsearch",
     type: :group,
     description: "Elasticsearch settings.",
     children: [
@@ -3334,13 +3336,13 @@ config :pleroma, :config_description, [
               },
               %{
                 key: :bulk_page_size,
-                type: :int,
+                type: :integer,
                 description: "Size for bulk put requests, mostly used on building the index",
                 suggestion: [5000]
               },
               %{
                 key: :bulk_wait_interval,
-                type: :int,
+                type: :integer,
                 description: "Time to wait between bulk put requests (in ms)",
                 suggestion: [15_000]
               }
@@ -3349,5 +3351,66 @@ config :pleroma, :config_description, [
         ]
       }
     ]
+  },
+  %{
+    group: :pleroma,
+    key: :translator,
+    type: :group,
+    description: "Translation Settings",
+    children: [
+      %{
+        key: :enabled,
+        type: :boolean,
+        description: "Is translation enabled?",
+        suggestion: [true, false]
+      },
+      %{
+        key: :module,
+        type: :module,
+        description: "Translation module.",
+        suggestions: {:list_behaviour_implementations, Pleroma.Akkoma.Translator}
+      }
+    ]
+  },
+  %{
+    group: :pleroma,
+    key: :deepl,
+    label: "DeepL",
+    type: :group,
+    description: "DeepL Settings.",
+    children: [
+      %{
+        key: :tier,
+        type: {:dropdown, :atom},
+        description: "API Tier",
+        suggestions: [:free, :pro]
+      },
+      %{
+        key: :api_key,
+        type: :string,
+        description: "API key for DeepL",
+        suggestions: [nil]
+      }
+    ]
+  },
+  %{
+    group: :pleroma,
+    key: :libre_translate,
+    type: :group,
+    description: "LibreTranslate Settings.",
+    children: [
+      %{
+        key: :url,
+        type: :string,
+        description: "URL for libretranslate",
+        suggestion: [nil]
+      },
+      %{
+        key: :api_key,
+        type: :string,
+        description: "API key for libretranslate",
+        suggestion: [nil]
+      }
+    ]
   }
 ]
index a29db208c21d20be82f2d36a588f065c339a2244..90041d3d64e85b2f420fa453a2ce60d291535084 100644 (file)
@@ -1159,3 +1159,28 @@ Each job has these settings:
 
 * `:max_running` - max concurrently runnings jobs
 * `:max_waiting` - max waiting jobs
+
+### Translation Settings
+
+Settings to automatically translate statuses for end users. Currently supported
+translation services are DeepL and LibreTranslate.
+
+Translations are available at `/api/v1/statuses/:id/translations/:language`, where
+`language` is the target language code (e.g `en`)
+
+### `:translator`
+
+- `:enabled` - enables translation
+- `:module` - Sets module to be used
+  - Either `Pleroma.Akkoma.Translators.DeepL` or `Pleroma.Akkoma.Translators.LibreTranslate`
+
+### `:deepl`
+
+- `:api_key` - API key for DeepL
+- `:tier` - API tier
+  - either `:free` or `:pro`
+
+### `:libre_translate`
+
+- `:url` - URL of LibreTranslate instance
+- `:api_key` - API key for LibreTranslate
\ No newline at end of file
diff --git a/lib/pleroma/akkoma/translators/deepl.ex b/lib/pleroma/akkoma/translators/deepl.ex
new file mode 100644 (file)
index 0000000..0a4a7fe
--- /dev/null
@@ -0,0 +1,58 @@
+defmodule Pleroma.Akkoma.Translators.DeepL do
+  @behaviour Pleroma.Akkoma.Translator
+
+  alias Pleroma.HTTP
+  alias Pleroma.Config
+  require Logger
+
+  defp base_url(:free) do
+    "https://api-free.deepl.com/v2/"
+  end
+
+  defp base_url(:pro) do
+    "https://api.deepl.com/v2/"
+  end
+
+  defp api_key do
+    Config.get([:deepl, :api_key])
+  end
+
+  defp tier do
+    Config.get([:deepl, :tier])
+  end
+
+  @impl Pleroma.Akkoma.Translator
+  def translate(string, to_language) do
+    with {:ok, %{status: 200} = response} <- do_request(api_key(), tier(), string, to_language),
+         {:ok, body} <- Jason.decode(response.body) do
+      %{"translations" => [%{"text" => translated, "detected_source_language" => detected}]} =
+        body
+
+      {:ok, detected, translated}
+    else
+      {:ok, %{status: status} = response} ->
+        Logger.warning("DeepL: Request rejected: #{inspect(response)}")
+        {:error, "DeepL request failed (code #{status})"}
+
+      {:error, reason} ->
+        {:error, reason}
+    end
+  end
+
+  defp do_request(api_key, tier, string, to_language) do
+    HTTP.post(
+      base_url(tier) <> "translate",
+      URI.encode_query(
+        %{
+          text: string,
+          target_lang: to_language
+        },
+        :rfc3986
+      ),
+      [
+        {"authorization", "DeepL-Auth-Key #{api_key}"},
+        {"content-type", "application/x-www-form-urlencoded"}
+      ]
+    )
+  end
+end
diff --git a/lib/pleroma/akkoma/translators/libre_translate.ex b/lib/pleroma/akkoma/translators/libre_translate.ex
new file mode 100644 (file)
index 0000000..615d041
--- /dev/null
@@ -0,0 +1,51 @@
+defmodule Pleroma.Akkoma.Translators.LibreTranslate do
+  @behaviour Pleroma.Akkoma.Translator
+
+  alias Pleroma.Config
+  alias Pleroma.HTTP
+  require Logger
+
+  defp api_key do
+    Config.get([:libre_translate, :api_key])
+  end
+
+  defp url do
+    Config.get([:libre_translate, :url])
+  end
+
+  @impl Pleroma.Akkoma.Translator
+  def translate(string, to_language) do
+    with {:ok, %{status: 200} = response} <- do_request(string, to_language),
+         {:ok, body} <- Jason.decode(response.body) do
+      %{"translatedText" => translated, "detectedLanguage" => %{"language" => detected}} = body
+
+      {:ok, detected, translated}
+    else
+      {:ok, %{status: status} = response} ->
+        Logger.warning("libre_translate: request failed, #{inspect(response)}")
+        {:error, "libre_translate: request failed (code #{status})"}
+
+      {:error, reason} ->
+        {:error, reason}
+    end
+  end
+
+  defp do_request(string, to_language) do
+    url = URI.parse(url())
+    url = %{url | path: "/translate"}
+
+    HTTP.post(
+      to_string(url),
+      Jason.encode!(%{
+        q: string,
+        source: "auto",
+        target: to_language,
+        format: "html",
+        api_key: api_key()
+      }),
+      [
+        {"content-type", "application/json"}
+      ]
+    )
+  end
+end
diff --git a/lib/pleroma/akkoma/translators/translator.ex b/lib/pleroma/akkoma/translators/translator.ex
new file mode 100644 (file)
index 0000000..0276ed6
--- /dev/null
@@ -0,0 +1,3 @@
+defmodule Pleroma.Akkoma.Translator do
+  @callback translate(String.t(), String.t()) :: {:ok, String.t(), String.t()} | {:error, any()}
+end
index e11e5495addd12d0044c48ae494318985cadc387..b809f77337bb62a72e86ed889987e97985b993e7 100644 (file)
@@ -154,7 +154,8 @@ defmodule Pleroma.Application do
       build_cachex("web_resp", limit: 2500),
       build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10),
       build_cachex("failed_proxy_url", limit: 2500),
-      build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000)
+      build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000),
+      build_cachex("translations", default_ttl: :timer.hours(24 * 30), limit: 2500)
     ]
   end
 
index a5da8b58e6a78c47e3ff18249d63c7a00c8f94e3..04a7bf5db3e394c9adacbf47507654927c5f05b5 100644 (file)
@@ -406,6 +406,22 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
     }
   end
 
+  def translate_operation do
+    %Operation{
+      tags: ["Retrieve status translation"],
+      summary: "Translate status",
+      description: "View the translation of a given status",
+      operationId: "StatusController.translation",
+      security: [%{"oAuth" => ["read:statuses"]}],
+      parameters: [id_param(), language_param()],
+      responses: %{
+        200 => Operation.response("Translation", "application/json", translation()),
+        400 => Operation.response("Error", "application/json", ApiError),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
   def array_of_statuses do
     %Schema{type: :array, items: Status, example: [Status.schema().example]}
   end
@@ -552,6 +568,10 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
     )
   end
 
+  defp language_param do
+    Operation.parameter(:language, :path, :string, "ISO 639 language code", example: "en")
+  end
+
   defp status_response do
     Operation.response("Status", "application/json", Status)
   end
@@ -573,4 +593,20 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
       }
     }
   end
+
+  defp translation do
+    %Schema{
+      title: "StatusTranslation",
+      description: "The translation of a status.",
+      type: :object,
+      required: [:detected_language, :text],
+      properties: %{
+        detected_language: %Schema{
+          type: :string,
+          description: "The detected language of the text"
+        },
+        text: %Schema{type: :string, description: "The translated text"}
+      }
+    }
+  end
 end
index 9ab30742bd16d6d87fe2adaaa02e6d3a3ef65041..d9b93ca5e8fb56ec74c13e068c426b8728b48960 100644 (file)
@@ -14,6 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
   alias Pleroma.Bookmark
   alias Pleroma.Object
   alias Pleroma.Repo
+  alias Pleroma.Config
   alias Pleroma.ScheduledActivity
   alias Pleroma.User
   alias Pleroma.Web.ActivityPub.ActivityPub
@@ -30,6 +31,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
   plug(:skip_public_check when action in [:index, :show])
 
   @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []}
+  @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
 
   plug(
     OAuthScopesPlug,
@@ -37,7 +39,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
     when action in [
            :index,
            :show,
-           :context
+           :context,
+           :translate
          ]
   )
 
@@ -418,6 +421,46 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
     )
   end
 
+  @doc "GET /api/v1/statuses/:id/translations/:language"
+  def translate(%{assigns: %{user: user}} = conn, %{id: id, language: language}) do
+    with {:enabled, true} <- {:enabled, Config.get([:translator, :enabled])},
+         %Activity{} = activity <- Activity.get_by_id_with_object(id),
+         {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
+         translation_module <- Config.get([:translator, :module]),
+         {:ok, detected, translation} <-
+           fetch_or_translate(
+             activity.id,
+             activity.object.data["content"],
+             language,
+             translation_module
+           ) do
+      json(conn, %{detected_language: detected, text: translation})
+    else
+      {:enabled, false} ->
+        conn
+        |> put_status(:bad_request)
+        |> json(%{"error" => "Translation is not enabled"})
+
+      {:visible, false} ->
+        {:error, :not_found}
+
+      e ->
+        e
+    end
+  end
+
+  defp fetch_or_translate(status_id, text, language, translation_module) do
+    @cachex.fetch!(:user_cache, "translations:#{status_id}:#{language}", fn _ ->
+      value = translation_module.translate(text, language)
+
+      with {:ok, _, _} <- value do
+        value
+      else
+        _ -> {:ignore, value}
+      end
+    end)
+  end
+
   defp put_application(params, %{assigns: %{token: %Token{user: %User{} = user} = token}} = _conn) do
     if user.disclose_client do
       %{client_name: client_name, website: website} = Repo.preload(token, :app).app
index 7ae357e2369239dad4b2f6541a1d0a7995a01a67..43651943906380be17dc8335006c6e5558f623a1 100644 (file)
@@ -81,6 +81,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
       if Config.get([:instance, :profile_directory]) do
         "profile_directory"
       end,
+      if Config.get([:translator, :enabled], false) do
+        "akkoma:machine_translation"
+      end,
       "custom_emoji_reactions"
     ]
     |> Enum.filter(& &1)
index 647d99278e0b6ace55913c13f15922562b8e8cd9..aff7b67db16a160a60b0d6b2de404b6883f82a36 100644 (file)
@@ -553,6 +553,7 @@ defmodule Pleroma.Web.Router do
     post("/statuses/:id/unbookmark", StatusController, :unbookmark)
     post("/statuses/:id/mute", StatusController, :mute_conversation)
     post("/statuses/:id/unmute", StatusController, :unmute_conversation)
+    get("/statuses/:id/translations/:language", StatusController, :translate)
 
     post("/push/subscription", SubscriptionController, :create)
     get("/push/subscription", SubscriptionController, :show)
diff --git a/test/pleroma/translators/deepl_test.exs b/test/pleroma/translators/deepl_test.exs
new file mode 100644 (file)
index 0000000..286d21d
--- /dev/null
@@ -0,0 +1,75 @@
+defmodule Pleroma.Akkoma.Translators.DeepLTest do
+  use Pleroma.DataCase, async: true
+
+  alias Pleroma.Akkoma.Translators.DeepL
+
+  describe "translating with deepl" do
+    setup do
+      clear_config([:deepl, :api_key], "deepl_api_key")
+    end
+
+    test "should work with the free tier" do
+      clear_config([:deepl, :tier], :free)
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "https://api-free.deepl.com/v2/translate"} = env ->
+          auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end)
+          assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                translations: [
+                  %{
+                    "text" => "I will crush you",
+                    "detected_source_language" => "ja"
+                  }
+                ]
+              })
+          }
+      end)
+
+      assert {:ok, "ja", "I will crush you"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", "en")
+    end
+
+    test "should work with the pro tier" do
+      clear_config([:deepl, :tier], :pro)
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "https://api.deepl.com/v2/translate"} = env ->
+          auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end)
+          assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                translations: [
+                  %{
+                    "text" => "I will crush you",
+                    "detected_source_language" => "ja"
+                  }
+                ]
+              })
+          }
+      end)
+
+      assert {:ok, "ja", "I will crush you"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", "en")
+    end
+
+    test "should gracefully fail if the API errors" do
+      clear_config([:deepl, :tier], :free)
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "https://api-free.deepl.com/v2/translate"} ->
+          %Tesla.Env{
+            status: 403,
+            body: ""
+          }
+      end)
+
+      assert {:error, "DeepL request failed (code 403)"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", "en")
+    end
+  end
+end
diff --git a/test/pleroma/translators/libre_translate_test.exs b/test/pleroma/translators/libre_translate_test.exs
new file mode 100644 (file)
index 0000000..9ed2c53
--- /dev/null
@@ -0,0 +1,91 @@
+defmodule Pleroma.Akkoma.Translators.LibreTranslateTest do
+  use Pleroma.DataCase, async: true
+
+  alias Pleroma.Akkoma.Translators.LibreTranslate
+
+  describe "translating with libre translate" do
+    setup do
+      clear_config([:libre_translate, :url], "http://libre.translate/translate")
+    end
+
+    test "should work without an API key" do
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "http://libre.translate/translate"} = env ->
+          assert {:ok, %{"api_key" => nil}} = Jason.decode(env.body)
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                detectedLanguage: %{
+                  confidence: 83,
+                  language: "ja"
+                },
+                translatedText: "I will crush you"
+              })
+          }
+      end)
+
+      assert {:ok, "ja", "I will crush you"} = LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", "en")
+    end
+
+    test "should work with an API key" do
+      clear_config([:libre_translate, :api_key], "libre_translate_api_key")
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "http://libre.translate/translate"} = env ->
+          assert {:ok, %{"api_key" => "libre_translate_api_key"}} = Jason.decode(env.body)
+
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                detectedLanguage: %{
+                  confidence: 83,
+                  language: "ja"
+                },
+                translatedText: "I will crush you"
+              })
+          }
+      end)
+
+      assert {:ok, "ja", "I will crush you"} = LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", "en")
+    end
+
+    test "should gracefully handle API key errors" do
+      clear_config([:libre_translate, :api_key], "")
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "http://libre.translate/translate"} ->
+          %Tesla.Env{
+            status: 403,
+            body:
+              Jason.encode!(%{
+                error: "Please contact the server operator to obtain an API key"
+              })
+          }
+      end)
+
+      assert {:error, "libre_translate: request failed (code 403)"} =
+               LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", "en")
+    end
+
+    test "should gracefully handle an unsupported language" do
+      clear_config([:libre_translate, :api_key], "")
+
+      Tesla.Mock.mock(fn
+        %{method: :post, url: "http://libre.translate/translate"} ->
+          %Tesla.Env{
+            status: 400,
+            body:
+              Jason.encode!(%{
+                error: "zoop is not supported"
+              })
+          }
+      end)
+
+      assert {:error, "libre_translate: request failed (code 400)"} =
+               LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", "zoop")
+    end
+  end
+end
index ea168f6c5183b5b3fd63ada2d485e2f7f19240c4..e38f5fe58d4e5ba6a9fd99cab27cf3f1008ee357 100644 (file)
@@ -2071,4 +2071,76 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
                |> json_response_and_validate_schema(422)
     end
   end
+
+  describe "translating statuses" do
+    setup do
+      clear_config([:translator, :enabled], true)
+      clear_config([:translator, :module], Pleroma.Akkoma.Translators.DeepL)
+      clear_config([:deepl, :api_key], "deepl_api_key")
+      oauth_access(["read:statuses"])
+    end
+
+    test "should return text and detected language", %{conn: conn} do
+      clear_config([:deepl, :tier], :free)
+
+      Tesla.Mock.mock_global(fn
+        %{method: :post, url: "https://api-free.deepl.com/v2/translate"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                translations: [
+                  %{
+                    "text" => "Tell me, for whom do you fight?",
+                    "detected_source_language" => "ja"
+                  }
+                ]
+              })
+          }
+      end)
+
+      user = insert(:user)
+      {:ok, to_translate} = CommonAPI.post(user, %{status: "何のために闘う?"})
+
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> get("/api/v1/statuses/#{to_translate.id}/translations/en")
+
+      response = json_response_and_validate_schema(conn, 200)
+
+      assert response["text"] == "Tell me, for whom do you fight?"
+      assert response["detected_language"] == "ja"
+    end
+
+    test "should not allow translating of statuses you cannot see", %{conn: conn} do
+      clear_config([:deepl, :tier], :free)
+
+      Tesla.Mock.mock_global(fn
+        %{method: :post, url: "https://api-free.deepl.com/v2/translate"} ->
+          %Tesla.Env{
+            status: 200,
+            body:
+              Jason.encode!(%{
+                translations: [
+                  %{
+                    "text" => "Tell me, for whom do you fight?",
+                    "detected_source_language" => "ja"
+                  }
+                ]
+              })
+          }
+      end)
+
+      user = insert(:user)
+      {:ok, to_translate} = CommonAPI.post(user, %{status: "何のために闘う?", visibility: "private"})
+
+      conn =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> get("/api/v1/statuses/#{to_translate.id}/translations/en")
+
+      json_response_and_validate_schema(conn, 404)
+    end
+  end
 end