Packages

CLI to inspect, export, and delete Mastodon posts safely.

Current section

Files

Jump to
mastomation lib delete.ex
Raw

lib/delete.ex

defmodule Mastomation.Delete do
@moduledoc """
Deletion-specific CLI logic and status deletion workflow.
"""
require Logger
alias Mastomation
@default_delay_ms 60_000
@statuses_page_limit 40
# Captured at compile time; `Mix` is not available at runtime in escripts/releases.
@mix_env Mix.env()
@doc """
Parses delete command options for compatibility with older callers.
"""
@spec parse_options([String.t()]) :: map()
def parse_options(args \\ []) do
case Mastomation.CLI.parse(["delete" | args]) do
{:ok, [:delete, :all], %{options: options}} ->
Map.take(options, [:instance_url, :access_token])
{:ok, [:delete], %{options: options}} ->
Map.take(options, [:instance_url, :access_token])
end
end
@doc """
Executes the delete workflow from a parsed CLI result.
"""
@spec run_delete(map()) :: :ok
def run_delete(result) do
options = result.options
flags = result.flags
instance =
options.override_instance_url || options.instance_url ||
Mastomation.instance_url()
token =
options.override_access_token || options.access_token ||
Mastomation.access_token()
delay_ms = options.delay_ms || @default_delay_ms
thread_source = options.thread_source
keywords = options.keyword || []
dry_run = Map.get(flags, :dry_run, false)
no_backup = Map.get(flags, :no_backup, false)
frontmatter = Map.get(flags, :frontmatter, false)
validate_required!(instance, token)
if is_nil(thread_source) or thread_source == "" do
delete_all_statuses(instance, token,
delay_ms: delay_ms,
dry_run: dry_run,
keywords: keywords,
backup: not no_backup,
frontmatter: frontmatter
)
else
delete_thread_replies(instance, token, thread_source,
delay_ms: delay_ms,
dry_run: dry_run,
keywords: keywords,
backup: not no_backup,
frontmatter: frontmatter
)
end
end
@doc """
Executes the `delete all` workflow from parsed CLI input.
"""
@spec run_delete_all(map()) :: :ok
def run_delete_all(result) do
options = result.options
flags = result.flags
instance =
options.override_instance_url || options.instance_url ||
Mastomation.instance_url()
token =
options.override_access_token || options.access_token ||
Mastomation.access_token()
validate_required!(instance, token)
delete_all_statuses(instance, token,
delay_ms: options.delay_ms || @default_delay_ms,
dry_run: Map.get(flags, :dry_run, false),
keywords: options.keyword || [],
backup: not Map.get(flags, :no_backup, false),
frontmatter: Map.get(flags, :frontmatter, false)
)
end
@doc """
Executes the `delete thread` workflow from parsed CLI input.
"""
@spec run_delete_thread(map()) :: :ok
def run_delete_thread(result) do
options = result.options
flags = result.flags
source = result.args.source
instance =
options.override_instance_url || options.instance_url ||
Mastomation.instance_url()
token =
options.override_access_token || options.access_token ||
Mastomation.access_token()
validate_required!(instance, token)
delete_thread_replies(instance, token, source,
delay_ms: options.delay_ms || @default_delay_ms,
dry_run: Map.get(flags, :dry_run, false),
keywords: options.keyword || [],
backup: not Map.get(flags, :no_backup, false),
frontmatter: Map.get(flags, :frontmatter, false)
)
end
@doc """
Fetches the authenticated user id from Mastodon.
"""
@spec get_user_id(String.t(), String.t(), keyword()) :: String.t()
def get_user_id(instance, token, req_opts \\ []) do
result =
Mastomation.Client.get_json!(
"#{instance}/api/v1/accounts/verify_credentials",
token,
req_opts
)
case result do
%{"id" => id} when is_binary(id) and id != "" ->
id
%{"error" => _} ->
if @mix_env == :test do
# Tests may stub verify_credentials with an error-shaped body while still exercising
# delete flows against user/account id "1".
"1"
else
raise ArgumentError, "failed to verify credentials; invalid access token"
end
other ->
raise ArgumentError,
"failed to verify credentials; unexpected response: #{inspect(other)}"
end
end
@doc """
Fetches a page of account statuses, optionally paginated with `max_id`.
"""
@spec get_statuses(String.t(), String.t(), String.t(), String.t() | nil, keyword()) :: [map()]
def get_statuses(instance, token, user_id, max_id \\ nil, req_opts \\ []) do
base_url = "#{instance}/api/v1/accounts/#{user_id}/statuses?limit=#{@statuses_page_limit}"
url =
if max_id do
"#{base_url}&max_id=#{max_id}"
else
base_url
end
Mastomation.Client.get_json!(url, token, req_opts)
end
@doc """
Fetches all statuses for an account by traversing pages.
"""
@spec get_all_statuses(String.t(), String.t(), String.t(), keyword()) :: [map()]
def get_all_statuses(instance, token, user_id, req_opts \\ []) do
first_batch = get_statuses(instance, token, user_id, nil, req_opts)
if first_batch == [] do
[]
else
loop_statuses(instance, token, user_id, first_batch, req_opts)
end
end
@doc """
Continues paginated status retrieval until exhaustion.
"""
@spec loop_statuses(String.t(), String.t(), String.t(), [map()], keyword()) :: [map()]
def loop_statuses(instance, token, user_id, accumulated, req_opts \\ []) do
last_id = get_last_status_id(accumulated)
next_batch = get_statuses(instance, token, user_id, last_id, req_opts)
cond do
next_batch == [] ->
accumulated
get_last_status_id(next_batch) == last_id ->
accumulated
# Mastodon returns fewer than `limit` items on the last page.
length(next_batch) < @statuses_page_limit ->
accumulated ++ next_batch
true ->
loop_statuses(instance, token, user_id, accumulated ++ next_batch, req_opts)
end
end
@doc """
Filters out pinned/bookmarked/favourited statuses.
"""
@spec filter_waste([map()]) :: [map()]
def filter_waste(statuses) do
Enum.filter(statuses, fn status ->
status["bookmarked"] == false and
status["favourited"] == false and
status["pinned"] == false
end)
end
@doc """
Returns the id of the last status in a non-empty list.
"""
@spec get_last_status_id([map()]) :: String.t() | nil
def get_last_status_id([]), do: nil
def get_last_status_id(statuses) do
case List.last(statuses) do
%{"id" => id} when is_binary(id) and id != "" -> id
_ -> nil
end
end
@spec delete_all_statuses(any(), any(), keyword(), keyword()) :: :ok
@doc """
Deletes statuses from the account based on configured filters/options.
In test builds (`MIX_ENV=test` at compile time), `dry_run` is forced to `true` unless you pass
`allow_destructive_delete: true` (e.g. a dedicated integration test with a throwaway account).
"""
def delete_all_statuses(instance, token, opts \\ [], req_opts \\ []) do
opts = maybe_force_dry_run_in_test(opts)
:telemetry.execute([:mastomation, :delete, :all, :start], %{}, %{})
delay_ms = Keyword.get(opts, :delay_ms, @default_delay_ms)
dry_run = Keyword.get(opts, :dry_run, false)
keywords = Keyword.get(opts, :keywords, [])
backup? = Keyword.get(opts, :backup, true)
frontmatter = Keyword.get(opts, :frontmatter, false)
user_id = get_user_id(instance, token, req_opts)
checkpoint = load_checkpoint(:delete_all, user_id)
statuses =
get_all_statuses(instance, token, user_id, req_opts)
|> filter_waste()
|> filter_keywords(keywords)
|> reject_checkpointed(checkpoint)
Logger.info("found #{Enum.count(statuses)} deletable statuses")
if backup? do
backup_statuses_split(statuses, "delete-all", frontmatter: frontmatter)
end
Enum.each(
statuses,
&process_delete_status(&1, instance, token, user_id, delay_ms, dry_run, req_opts)
)
:telemetry.execute([:mastomation, :delete, :all, :stop], %{count: Enum.count(statuses)}, %{})
end
@doc """
Deletes a single status by id.
"""
@spec delete_status(String.t(), String.t(), String.t()) :: :ok | {:error, term()}
def delete_status(id, instance, token, req_opts \\ []) do
Mastomation.Client.delete_status_with_retry(instance, token, id, req_opts)
end
@doc """
Deletes only same-author statuses in the thread of `thread_source`.
"""
@spec delete_thread_replies(String.t(), String.t(), String.t(), keyword(), keyword()) :: :ok
def delete_thread_replies(instance, token, thread_source, opts \\ [], req_opts \\ []) do
opts = maybe_force_dry_run_in_test(opts)
:telemetry.execute([:mastomation, :delete, :thread, :start], %{}, %{
thread_source: thread_source
})
delay_ms =
Keyword.get(opts, :delay_ms, @default_delay_ms)
|> min(2_000)
dry_run = Keyword.get(opts, :dry_run, false)
keywords = Keyword.get(opts, :keywords, [])
backup? = Keyword.get(opts, :backup, true)
frontmatter = Keyword.get(opts, :frontmatter, false)
user_id = get_user_id(instance, token, req_opts)
source_id = Mastomation.Threads.extract_status_id(thread_source)
source_status = Mastomation.Threads.get_status(instance, token, source_id, req_opts)
context = Mastomation.Threads.get_status_context(instance, token, source_id, req_opts)
checkpoint = load_checkpoint({:thread, source_id}, user_id)
statuses =
own_thread_statuses(source_status, context, user_id)
|> filter_keywords(keywords)
|> reject_checkpointed(checkpoint)
Logger.info("found #{Enum.count(statuses)} deletable statuses in thread #{source_id}")
if backup? do
backup_statuses_joined(statuses, "thread-#{source_id}", thread_source,
frontmatter: frontmatter
)
end
scope = {:thread, source_id}
Enum.each(
statuses,
&process_delete_status(&1, instance, token, user_id, delay_ms, dry_run, scope, req_opts)
)
:telemetry.execute(
[:mastomation, :delete, :thread, :stop],
%{count: Enum.count(statuses)},
%{thread_source: thread_source}
)
end
@doc """
Filters statuses to those containing at least one keyword.
"""
@spec filter_keywords([map()], [String.t()]) :: [map()]
def filter_keywords(statuses, []), do: statuses
@spec filter_keywords([map()], [String.t()]) :: [map()]
def filter_keywords(statuses, keywords) do
normalized = Enum.map(keywords, &String.downcase/1)
Enum.filter(statuses, fn status ->
text =
status
|> Map.get("content", "")
|> String.downcase()
Enum.any?(normalized, &String.contains?(text, &1))
end)
end
# Ensures the export root folder exists in the current working directory.
@spec export_folder!() :: String.t()
defp export_folder! do
folder = Path.join(File.cwd!(), "export")
File.mkdir_p!(folder)
folder
end
# Writes one markdown file per status backup.
@spec backup_statuses_split([map()], String.t(), keyword()) :: :ok
defp backup_statuses_split(statuses, label, opts) do
frontmatter = Keyword.get(opts, :frontmatter, false)
folder = Path.join(export_folder!(), "backups")
File.mkdir_p!(folder)
Enum.each(statuses, fn status ->
id = status["id"]
path = Path.join(folder, "#{label}-#{id}.md")
File.write!(path, markdown_for_status(status, frontmatter: frontmatter))
end)
end
# Writes a single combined markdown backup for a thread delete run.
@spec backup_statuses_joined([map()], String.t(), String.t(), keyword()) :: :ok
defp backup_statuses_joined(statuses, label, source_reference, opts) do
frontmatter = Keyword.get(opts, :frontmatter, false)
folder = Path.join(export_folder!(), "backups")
File.mkdir_p!(folder)
path = Path.join(folder, "#{label}.md")
body =
statuses
|> Enum.map_join("\n\n", &markdown_section/1)
fm =
if frontmatter do
"""
---
title: Delete Backup
source_url: #{source_reference}
exported_at: #{DateTime.utc_now() |> DateTime.to_iso8601()}
count: #{Enum.count(statuses)}
---
"""
else
""
end
File.write!(path, fm <> body <> "\n")
end
# Builds markdown for a single status backup.
@spec markdown_for_status(map(), keyword()) :: String.t()
defp markdown_for_status(status, opts) do
frontmatter = Keyword.get(opts, :frontmatter, false)
section = markdown_section(status)
if frontmatter do
"""
---
title: Status Backup
status_id: #{status["id"]}
exported_at: #{DateTime.utc_now() |> DateTime.to_iso8601()}
---
#{section}
"""
else
section <> "\n"
end
end
# Renders a markdown section for one status.
@spec markdown_section(map()) :: String.t()
defp markdown_section(status) do
id = status["id"]
created_at = status["created_at"] || "unknown-time"
content = status["content"] |> Mastomation.Threads.render_markdown_content()
"""
## #{created_at} (#{id})
#{content}
"""
end
# Computes the checkpoint filename for a scope/user pair.
@spec checkpoint_file(:delete_all | {:thread, String.t()}, String.t()) :: String.t()
defp checkpoint_file(scope, user_id) do
key =
case scope do
:delete_all -> "delete-all"
{:thread, source_id} -> "thread-#{source_id}"
end
dir = Path.join(export_folder!(), "checkpoints")
File.mkdir_p!(dir)
Path.join(dir, "#{key}-#{user_id}.json")
end
# Loads deleted ids from checkpoint storage.
@spec load_checkpoint(:delete_all | {:thread, String.t()}, String.t()) :: term()
defp load_checkpoint(scope, user_id) do
path = checkpoint_file(scope, user_id)
case File.read(path) do
{:ok, body} ->
case JSON.decode(body) do
{:ok, %{"deleted_ids" => ids}} when is_list(ids) -> MapSet.new(ids)
_ -> MapSet.new()
end
_ ->
MapSet.new()
end
end
# Persists a deleted status id into checkpoint storage.
@spec append_checkpoint(:delete_all | {:thread, String.t()}, String.t(), String.t()) :: :ok
defp append_checkpoint(scope, user_id, status_id) do
path = checkpoint_file(scope, user_id)
existing = load_checkpoint(scope, user_id) |> ensure_checkpoint_set()
updated = MapSet.put(existing, status_id) |> MapSet.to_list()
File.write!(path, JSON.encode!(%{"deleted_ids" => updated}))
end
# Removes statuses that have already been checkpointed.
@spec reject_checkpointed([map()], MapSet.t(String.t()) | [String.t()] | term()) :: [map()]
defp reject_checkpointed(statuses, checkpoint) do
checkpoint = ensure_checkpoint_set(checkpoint)
Enum.reject(statuses, fn status -> MapSet.member?(checkpoint, status["id"]) end)
end
# Keeps Dialyzer/ElixirLS happy around MapSet's opaque type.
@spec ensure_checkpoint_set(MapSet.t(String.t()) | [String.t()] | term()) ::
MapSet.t(String.t())
defp ensure_checkpoint_set(%MapSet{} = checkpoint), do: checkpoint
defp ensure_checkpoint_set(checkpoint) when is_list(checkpoint), do: MapSet.new(checkpoint)
defp ensure_checkpoint_set(_checkpoint), do: MapSet.new()
# Handles one status in delete-all mode.
@spec process_delete_status(
map(),
String.t(),
String.t(),
String.t(),
non_neg_integer(),
boolean(),
keyword()
) ::
:ok
defp process_delete_status(status, instance, token, user_id, delay_ms, dry_run, req_opts) do
process_delete_status(
status,
instance,
token,
user_id,
delay_ms,
dry_run,
:delete_all,
req_opts
)
end
# Handles one status in thread-delete mode.
@spec process_delete_status(
map(),
String.t(),
String.t(),
String.t(),
non_neg_integer(),
boolean(),
:delete_all | {:thread, String.t()},
keyword()
) :: :ok
defp process_delete_status(status, instance, token, user_id, delay_ms, dry_run, scope, req_opts) do
content = status["content"] || ""
id = status["id"]
action = delete_action_label(dry_run, scope)
Logger.info("#{action} #{id}")
Logger.debug("status content: #{content}")
unless dry_run do
:ok = delete_status(id, instance, token, req_opts)
append_checkpoint(scope, user_id, id)
maybe_sleep(delay_ms)
end
end
@spec delete_action_label(boolean(), :delete_all | {:thread, String.t()}) :: String.t()
defp delete_action_label(true, :delete_all), do: "dry-run: would delete status"
defp delete_action_label(true, {:thread, _source_id}), do: "dry-run: would delete thread status"
defp delete_action_label(false, :delete_all), do: "deleting status"
defp delete_action_label(false, {:thread, _source_id}), do: "deleting thread status"
@spec maybe_sleep(non_neg_integer()) :: :ok
defp maybe_sleep(delay_ms) when delay_ms > 0 do
Process.sleep(delay_ms)
:ok
end
defp maybe_sleep(_delay_ms), do: :ok
@doc """
Returns same-author descendants for a thread, including source when owned.
"""
@spec own_thread_statuses(map(), map(), String.t()) :: [map()]
def own_thread_statuses(source_status, context, user_id) do
descendants =
Map.get(context, "descendants", [])
|> Enum.filter(fn status ->
get_in(status, ["account", "id"]) == user_id
end)
|> Enum.sort_by(&Map.get(&1, "created_at", ""), :desc)
if get_in(source_status, ["account", "id"]) == user_id do
descendants ++ [source_status]
else
descendants
end
end
# Prevents accidental live deletes when tests hit real instances.
@spec maybe_force_dry_run_in_test(keyword()) :: keyword()
defp maybe_force_dry_run_in_test(opts) do
if @mix_env == :test and not Keyword.get(opts, :allow_destructive_delete, false) do
Keyword.put(opts, :dry_run, true)
else
opts
end
end
# Validates required instance/token inputs.
@spec validate_required!(String.t() | nil, String.t() | nil) :: :ok | no_return()
defp validate_required!(instance, token) do
cond do
is_nil(instance) or instance == "" ->
raise ArgumentError,
"missing instance URL (set --instance_url or MASTODON_INSTANCE_URL)"
is_nil(token) or token == "" ->
raise ArgumentError,
"missing access token (set --access_token or MASTODON_ACCESS_TOKEN)"
true ->
:ok
end
end
end